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
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
database
sessions
images
downloads
.git
.github
*.db
*.sqlite
.env
.dockerignore
+62
View File
@@ -0,0 +1,62 @@
# ============================================
# Agora — Configuration
# ============================================
# --- Plugin : Zone-Téléchargement (Source par défaut) ---
ZT_URL= #https://...
# --- Autres sources ---
# ZTTEAM_URL=
# FT_URL=
# --- Plugin : Base de Données Locale SQLite (Optionnel) ---
# DB_PATH=./database/darkiworld.db
# --- Enrichissement TMDB (Optionnel) ---
TMDB_ENABLED=false
TMDB_API_KEY=
# --- Plugin : FS24 (Optionnel, nécessite un compte) ---
# FS24_URL=
# FS24_USERNAME=
# FS24_PASSWORD=
# --- Plugin : Movix (Optionnel) ---
# MOVIX_URL=
# --- Plugin : FlixArt (Optionnel, nécessite un compte) ---
# FLIXART_URL=
# FLIXART_USERNAME=
# FLIXART_PASSWORD=
# --- Plugin : Loadix (Optionnel) ---
# LOADIX_URL=
# --- Configuration Application ---
PORT=3067
SECRET=generer-une-cle-aleatoire-ici
# --- Admin auto-bootstrap (Optionnel) ---
# Si définis, le compte admin est créé automatiquement au premier lancement.
# Si absents, accédez à /setup pour créer le premier admin manuellement.
# ADMIN_USERNAME=admin
# ADMIN_PASSWORD=agora
# --- Paramètres de scan ---
MIN_MINUTES=15
MAX_MINUTES=30
# --- JDownloader (Optionnel) ---
# JD_HOST=192.168.1.100
# JD_API_PORT=3128
# PATHS_JD_WATCH=C:\Users\nom\Documents\Nouveau dossier\
# ⚠️ Attention : Les chemins PATHS_JD_FILMS et PATHS_JD_SERIES doivent impérativement finir par un '/' ou '\'
# PATHS_JD_FILMS=C:\Users\nom\Documents\Nouveau dossier\Films\
# PATHS_JD_SERIES=C:\Users\nom\Documents\Nouveau dossier\Series\
# JD_CREATE_SUBFOLDER=true
# JD_AUTOSTART=true
# JD_FORCED_START=false
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [NoNoBzH22] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+61
View File
@@ -0,0 +1,61 @@
name: Docker Image CI
on:
# Se déclenche automatiquement quand tu publies une nouvelle "Release" sur GitHub
release:
types: [published]
# Permet de lancer le script manuellement depuis l'interface GitHub (onglet Actions)
workflow_dispatch:
env:
REGISTRY: ghcr.io
# On récupère le nom de ton dépôt (ex: nonobzh22/hydr-hacked)
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # Nécessaire pour pousser l'image sur le GitHub Container Registry
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Cette étape met le nom de l'image en minuscules pour éviter les erreurs Docker
- name: Lowercase the image name
run: echo "IMAGE_NAME=$(echo ${{ env.IMAGE_NAME }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
# GitHub fournit automatiquement ces identifiants lors de l'exécution, pas besoin de les créer !
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ github.event_name == 'release' || github.ref == 'refs/heads/main' }}
type=ref,event=branch
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+11
View File
@@ -0,0 +1,11 @@
node_modules/
.env
sessions/
.DS_Store
*.crawljob
/downloads/
dist/
database/darkiworld.db
database/settings.json
database/users.json
scripts/
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/views ./views
COPY --from=builder /app/public ./public
EXPOSE 3067
CMD ["node", "--dns-result-order=ipv4first", "dist/src/index.js"]
+40
View File
@@ -0,0 +1,40 @@
# Couleurs pour l'affichage
BLUE = \033[0;34m
GREEN = \033[0;32m
YELLOW = \033[0;33m
NC = \033[0m
.PHONY: all install build start launch dev clean help
# Default target: show help
all: help
help: ## Affiche ce message d'aide
@echo "$(BLUE)Commande disponibles :$(NC)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-15s$(NC) %s\n", $$1, $$2}'
install: node_modules ## Installation des dépendances
node_modules: package.json package-lock.json
@echo "$(YELLOW)Vérification/Installation des dépendances...$(NC)"
npm install
@touch node_modules
build: node_modules ## Compilation TypeScript vers JavaScript
@echo "$(YELLOW)Compilation du projet...$(NC)"
npm run build
@echo "$(GREEN)Compilation terminée!$(NC)"
start: build ## Compilation et lancement du projet
@echo "$(YELLOW)Lancement du projet...$(NC)"
npm start
dev: node_modules ## Lancement en mode développement (hot reload)
@echo "$(YELLOW)Lancement du serveur en mode développement...$(NC)"
npm run dev
clean: ## Suppression des fichiers de build et temporaires
@echo "$(YELLOW)Nettoyage...$(NC)"
@rm -rf dist
@rm -rf sessions/*.json 2>/dev/null || true
@echo "$(GREEN)Nettoyage terminé!$(NC)"
+203
View File
@@ -0,0 +1,203 @@
# 🐍 Agora
> [!IMPORTANT]
> Merci de bien lire tout ça avant de déployer le server
> Si vous êtes débutant(e) cette vidéo devrait répondre à vos questions
[Vidéo tutoriel + DB](https://gofile.io/d/3CA4rk)
![Agora Logo](public/images/logo_svg.svg)
> "Un immense merci à l'équipe technique d'Hydracker pour sa générosité. On a trouvé votre API tellement 'ouverte d'esprit' qu'on s'est permis de l'aider à partager ses liens sans les contraintes futiles d'un navigateur ou d'un abonnement. C'est presque trop facile, mais comme on dit : c'est l'intention qui compte." 💅
---
## 🚀 Présentation
**Agora** est une solution complète (Serveur API + Interface Web) pour crawler, rechercher et télécharger du contenu depuis plusieurs sources :
- 🆓 **Zone-Telechargement (ZT)** : Source principale, 100% gratuite et sans token (films et séries). Les affiches et titres sont automatiquement enrichis par TMDB.
- 📰 **ZTNews** : Source secondaire gratuite (Zone-Téléchargement News) pour des exclusivités et nouveaux ajouts.
-**FreeTélécharger (FreeTel)** : Source alternative gratuite avec de multiples miroirs.
- 🍿 **FS24** : Source spécialisée pour des films, séries et animés via streaming/téléchargement direct communautaire (nécessite un compte).
- 🎬 **FlixArt** : Source communautaire avec liens DDL, qualités et langues détaillées (nécessite un compte).
- 📦 **Loadix** : Source communautaire avec une API JSON propre — tendances, récents, recherche et qualités (pas de compte requis).
- 🎥 **Movix** : Source alternative gratuite de films et séries.
- 🗄️ **LocalDB** : Base de données locale intégrée pour des recherches hors-ligne instantanées (Films, Séries, Jeux, Logiciels, Musique, etc.).
- 🛡️ **Hydracker** : Source premium secondaire (nécessite un token et une configuration).
> [!IMPORTANT]
> **Nouveauté 1.5.2 :** Ajout du plugin FS24 avec support des tendances et ajouts récents, correction de l'enrichissement TMDB pour ZT, et sécurisation des paramètres utilisateurs (qui sont désormais stockés dans `database/config.json` et n'écrasent plus votre fichier `.env`).
> **Nouveauté 1.5.4 :** Petite correction d'urgence concernant un encodage d'URL qui faisait planter les appels à l'API Hydracker (Erreur 401 Unauthorized sur les channels et titles).
> **Nouveauté 1.5.5 :**
> - **Hydracker** : Correction de l'erreur 403 sur l'accès aux liens en utilisant l'endpoint `/download` pour les films et les séries. Les saisons sont désormais récupérées directement via la fiche du titre, et les liens de séries s'obtiennent en itérant sur les épisodes.
> - **FreeTélécharger** : Mise à jour de l'expression régulière du domaine pour supporter le nouveau TLD `.biz` (et tout autre changement de TLD futur pour `liens.free-telecharger.*`).
>
> **Nouveauté 1.5.6 :**
> - **Loadix** : Nouveau plugin communautaire avec recherche, tendances, ajouts récents et affichage détaillé des qualités/langues/tailles. Les liens sont protégés par Cloudflare Turnstile — l'utilisateur est redirigé vers le site pour résoudre le captcha manuellement.
> - **FlixArt** : Nettoyage complet du plugin — suppression de toute la logique Turnstile embarquée (impossible à résoudre en local). L'utilisateur est désormais redirigé vers le site source.
> - **Architecture** : Toutes les URLs sont désormais 100% dynamiques via `database/config.json` — plus aucun lien en dur dans le code source.
>
> La db locale (LocalDB) est au même endroit que la vidéo tuto ;) au dessus.
>
> **Nouveauté 1.5.9 :**
> - **Movix & TMDB** : Implémentation directe de l'API TMDB pour des tendances et récents ultra qualitatifs. Recherche automatique en arrière-plan lors du clic.
> - **Filtres et Interface** : Ajout d'une icône TMDB sur chaque affiche pour faire des recherches rapidement, et filtres Films/Séries sur la page des ajouts récents.
> - **JDownloader** : Option de 'Démarrage forcé' indépendante de l'ajout automatique.
> - **Nettoyage** : Disparition d'Hydracker et nettoyage silencieux d'Uptobox.
## ✨ Fonctionnalités
- 🔍 **Recherche & Tendances** : Chercher vos films et séries ou récupérer les tendances.
- 🗄️ **Base de Données Locale** : Recherche instantanée et hors-ligne grâce au plugin natif LocalDB.
- 💻 **Interface web** : Interface web moderne et responsive (Dark Mode, animations fluides).
- 🔗 **Affichage des liens** : Copier-coller le lien final s'affiche en un clic.
-**Intégration JDownloader** : Envoi automatique des liens vers votre instance JDownloader (si activé dans les paramètres).
## 🔑 Ce qui nécessite (ou pas) un token
| Fonctionnalité | 100% gratuit |
|---|---|
| 🔍 Recherche | ✅ Gratuit (ZT / LocalDB) |
| 🔥 Tendances | ✅ Gratuit (ZT) |
| 🎬 Films (liens 1fichier) | ✅ Gratuit (ZT / LocalDB) |
| 🖼️ Affiches (posters) | ✅ Gratuit (proxy intégré) |
| 📺 Séries (liens 1fichier) | ✅ Gratuit (ZT / LocalDB) |
| 🎮 Jeux / Logiciels / Ebooks | ✅ Gratuit (LocalDB uniquement) |
---
## 📸 Screenshots
### Interface Web
![Screenshot](images/screenshot_tendances.png)
### Qualités
![Screenshot](images/screenshot_quality.png)
---
## 🛠️ Installation
### 🐳 Via Docker (Recommandé)
C'est la méthode la plus simple pour garder un environnement propre. Nous utilisons désormais une image pré-construite qui se met à jour automatiquement.
```bash
# 1. Cloner le projet (si ce n'est pas déjà fait)
git clone https://github.com/NoNoBzH22/Agora
# 2. Préparer la configuration
cp .env.example .env
# 3. Lancer l'application
docker compose up -d
```
📍 Accès : `http://localhost:3067`
> [!TIP]
> L'application utilise l'image `ghcr.io/nonobzh22/Agora:latest`. Elle est reconstruite automatiquement à chaque mise à jour, vous n'avez plus besoin de compiler localement.
---
### 💻 Installation Manuelle
Pour ceux qui préfèrent une installation classique.
**Prérequis :** [Node.js](https://nodejs.org/) v20+
```bash
# 1. Préparer la configuration
cp .env.example .env
# 2. Installer les dépendances
npm install
# 3. Lancer l'application (compiler et démarrer)
npm run build && npm start
```
> [!TIP]
> Si vous avez `make` installé, vous pouvez simplifier les commandes :
> - `make start` : Installe, compile et lance l'application.
> - `make dev` : Développement avec rechargement automatique (ou `npm run dev`).
📍 Accès : `http://localhost:3067`
---
### ⚙️ Configuration (.env)
Créez un fichier `.env` à la racine du projet et configurez les variables suivantes :
| Variable | Type | Description |
|---|---|---|
| `ZT_URL` | **Requis** | URL complète du site Zone-Telechargement. |
| `ZTNEWS_URL` | Optionnel | URL complète de la source ZTNews. |
| `FT_URL` | Optionnel | URL complète de la source FreeTélécharger. |
| `HYDRACKER_URL` | Optionnel | URL complète de votre instance Hydracker (nécessaire si plugin actif). |
| `HYDRACKER_API_KEY` | Optionnel | Votre token Hydracker. |
| `FS24_URL` | Optionnel | URL complète de FS24. |
| `FS24_USERNAME` | Optionnel | Identifiant FS24. |
| `FS24_PASSWORD` | Optionnel | Mot de passe FS24. |
| `MOVIX_URL` | Optionnel | URL complète de Movix. |
| `FLIXART_URL` | Optionnel | URL complète de FlixArt. |
| `FLIXART_USERNAME` | Optionnel | Identifiant FlixArt. |
| `FLIXART_PASSWORD` | Optionnel | Mot de passe FlixArt. |
| `LOADIX_URL` | Optionnel | URL complète de Loadix. |
| `SECRET` | **Requis** | Clé secrète pour les sessions. |
| `PORT` | Optionnel | Port de l'application (Défaut : `3067`). |
| `DB_PATH` | Optionnel | Chemin vers la base locale (Défaut : `./database/darkiworld.db`). |
| `JD_HOST` | Optionnel | IP/Hôte de JDownloader. |
| `JD_API_PORT` | Optionnel | Port API de JDownloader (Défaut : `3128`). |
> [!WARNING]
> **Les URLs des sites sources** ne sont volontairement pas renseignées par défaut. Vous devez les remplir vous-même avec les URLs des sites sources respectifs.
> [!TIP]
> **Comment obtenir ma `HYDRACKER_API_KEY` ?**
> Connectez-vous sur votre instance Hydracker, cherchez la page **Paramètres du compte** et descendez jusqu'à **Jetons d'accès API**.
> Cliquez sur **Créer un jeton** et copiez le token généré dans le champ `HYDRACKER_API_KEY` de votre `.env`.
## 🧩 Créer un nouveau Plugin
L'architecture d'Agora est modulaire. Vous pouvez facilement ajouter une nouvelle source en créant un plugin qui implémente l'interface `ISource`.
### 1. Structure
Créez un dossier dans `plugins/[NomDeVotreSource]/`. Vous aurez généralement besoin de :
- `index.ts` : Point d'entrée et implémentation de la classe.
- `api.ts` : Fonctions d'appels réseau.
- `parser.ts` : Logique d'extraction des données (Cheerio, JSON, etc.).
### 2. Implémentation
Votre classe doit implémenter `ISource` (`src/types/source.ts`) :
```typescript
export interface ISource {
name: string;
healthCheck(): Promise<boolean>;
search(query: string, mediaType?: MediaType): Promise<SearchResult[]>;
getTrending(mediaType: MediaType): Promise<SearchResult[]>;
getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData>;
resolveLink?(linkId: string): Promise<string | null>; // Optionnel
}
```
### 3. Enregistrement
À la fin de votre fichier `index.ts`, enregistrez votre source :
```typescript
sourceRegistry.register(new VotrePluginAPI(CONFIG.VOTRE_URL));
```
Le serveur découvrira et chargera automatiquement votre plugin au démarrage.
## 🤝 Un Projet Communautaire
**Agora** est un projet fait par la communauté, pour la communauté. Parce que le savoir (et les liens de téléchargement) ne devrait jamais être prisonnier derrière des murs de paye ou des scripts de sécurité mal conçus.
Chaque Pull Request est la bienvenue, tant qu'elle contribue à rendre l'accès encore plus fluide et... disons, "généreux".
## Note Liminaire
Cet outil est une preuve de concept destinée à la recherche et à l'apprentissage. Son auteur ne cautionne aucun usage abusif ni aucune violation de droits tiers. Il appartient à chaque utilisateur de s'assurer que ses activités restent conformes à la législation ; la responsabilité de l'usage incombe exclusivement à l'utilisateur final.
## 📜 Licence
Projet sous licence MIT. Faites-en bon usage (ou pas, on ne juge pas).
View File
+29
View File
@@ -0,0 +1,29 @@
{
"PREFERRED_HOSTERS": [
"1fichier",
"nitroflare",
"ddownload",
"rapidgator",
"gofile",
"mega",
"pixeldrain",
"turbobit"
],
"HYDRACKER_URL": "https://hydracker.com",
"HYDRACKER_API_KEY": "107571|UcmgBErjph7kwI3B9aF5oAG9ga9WgUMV0IpYTXvuc1e04687",
"ZT_URL": "https://zone-telechargement.org",
"ZTTEAM_URL": "https://www.zone-telechargement.land",
"FT_URL": "https://www.free-telecharger.skin",
"TMDB_ENABLED": "true",
"TMDB_API_KEY": "67900f5a59c80873a70d9a3523152584",
"FS24_URL": "https://fs24.lol",
"FS24_USERNAME": "NoNoBzH",
"FS24_PASSWORD": "WHpE57PjpEk9WgT",
"MAX_RESULTS_PER_SOURCE": "20",
"MOVIX_URL": "https://movix.show",
"FLIXART_URL": "https://flixart.net",
"FLIXART_USERNAME": "pipiano663",
"FLIXART_PASSWORD": "j3WXnu3eHT8JdLy",
"LOADIX_URL": "https://loadix.fun",
"JD_FORCED_START": "false"
}
+1
View File
@@ -0,0 +1 @@
flixart_device_id=6q_WcxRA-2VfIATy0NvR3QFATnfQCpkod13Vci6-veM; _lscache_vary=c993f0a67d154231b65a323e8e99d4e8; wordpress_sec_7991dcc7dbc2d1c8d081bc45babbe5dd=pipiano663%7C1789577571%7CwDGBay3HyJdEODgexMhcCJHv7lsiKr7QNKetDNTuc6p%7C5eb4fc042ef8cb12d6e7413ce6acb13a88f675bd0cefe5d98d6fc099cc8327bc; wordpress_sec_7991dcc7dbc2d1c8d081bc45babbe5dd=pipiano663%7C1789577571%7CwDGBay3HyJdEODgexMhcCJHv7lsiKr7QNKetDNTuc6p%7C5eb4fc042ef8cb12d6e7413ce6acb13a88f675bd0cefe5d98d6fc099cc8327bc; wordpress_logged_in_7991dcc7dbc2d1c8d081bc45babbe5dd=pipiano663%7C1789577571%7CwDGBay3HyJdEODgexMhcCJHv7lsiKr7QNKetDNTuc6p%7Cccca6252080e8f8fccc171cdef684b6edcf0e834e9c63849b0885e8ac090fc27
+1
View File
@@ -0,0 +1 @@
PHPSESSID=d07cfe828766fcaf0b84d923ad7e14e7; dle_user_id=1954710; 05-Aug-2027 11:23:46 GMT; dle_password=a1fd7d561bf7816da6208f387527e3c8; 05-Aug-2027 11:23:46 GMT; fss_dvt=2976548; 05-Aug-2026 11:33:46 GMT; dle_newpm=0; 05-Aug-2027 11:23:46 GMT
+20
View File
@@ -0,0 +1,20 @@
services:
agora:
image: nonobzh22/agora:latest
container_name: agora_app
restart: unless-stopped
ports:
- "${PORT:-3067}:${PORT:-3067}"
env_file:
- .env
volumes:
- ./sessions:/app/sessions
- ./downloads:/downloads
- ./images:/app/images
- ./database:/app/database
deploy:
resources:
limits:
memory: 1024M
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

+1812
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "Agora",
"version": "1.5.9",
"type": "module",
"description": "Agora - API Proxy and Frontend",
"main": "server.js",
"scripts": {
"start": "node --experimental-sqlite dist/src/index.js",
"build": "tsc",
"dev": "tsx --experimental-sqlite src/index.ts"
},
"dependencies": {
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"ejs": "^5.0.2",
"express": "^4.19.2",
"express-rate-limit": "^7.2.0",
"express-session": "^1.18.0",
"helmet": "^7.1.0",
"session-file-store": "^1.5.0"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/node": "^25.6.1",
"@types/session-file-store": "^1.2.6",
"tsx": "^4.21.0",
"typescript": "^6.0.3"
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Appels réseau pour le plugin ZT.
* Toutes les fonctions fetch sont ici ; le parsing reste dans parser.ts.
*/
export async function fetchSearchResults(baseUrl: string, query: string): Promise<string> {
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=0&q=${encodeURIComponent(query)}&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`;
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0',
'Accept': 'text/html, */*',
'X-Requested-With': 'XMLHttpRequest',
'Referer': baseUrl
}
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchTrendingMovies(baseUrl: string): Promise<string> {
const res = await fetch(`${baseUrl}/engine/ajax/controller.php?mod=filter&catid=3&q=&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchTrendingSeries(baseUrl: string): Promise<string> {
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=15&q=&art=0&AiffchageMode=0&inputTirePar=1&cstart=0`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchContentPage(pageUrl: string): Promise<string> {
const res = await fetch(pageUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchResolvedLink(zoneursUrl: string): Promise<string> {
const url = zoneursUrl.startsWith('//') ? `https:${zoneursUrl}` : zoneursUrl;
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3',
}
});
if (!res.ok) throw new Error(`HTTP ${res.status} sur ${url}`);
return res.text();
}
export async function fetchRecent(baseUrl: string): Promise<string> {
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=55&q=&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
+162
View File
@@ -0,0 +1,162 @@
import { ISource, SearchResult, MediaType, ContentLinks, SelectionData } from '../../src/types/source.js';
import { CONFIG } from '../../src/utils/config.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { fetchSearchResults, fetchTrendingMovies, fetchTrendingSeries, fetchContentPage, fetchResolvedLink, fetchRecent } from './api.js';
import { parseSearchHTML, parseContentHTML, extractLinkFromZtProtect } from './parser.js';
/**
* Normalise un titre pour la comparaison (minuscules, sans accents, sans ponctuation).
*/
function normalizeTitle(title: string): string {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/-\s*saison\s*\d+/gi, '')
.replace(/\(\s*\d{4}\s*\)/g, '')
.replace(/[^a-z0-9]/g, '');
}
/**
* Déduplique les résultats par titre normalisé, en gardant la première occurrence.
*/
function deduplicateByTitle(results: SearchResult[]): SearchResult[] {
const seen = new Set<string>();
return results.filter(r => {
const key = normalizeTitle(r.title);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export class ZoneTelechargementAPI implements ISource {
name = 'zt';
displayName = 'Zone-Téléchargement';
get baseUrl() {
return CONFIG.ZT_URL?.replace(/\/$/, '');
}
async healthCheck(): Promise<boolean> {
if (!this.baseUrl) {
console.warn('[ZT] ⚠️ ZT_URL non définie.');
return false;
}
return true;
}
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
if (!this.baseUrl) throw new Error('ZT_URL non configurée.');
if (!query || query.length < 4) throw new Error('La recherche nécessite au moins 4 caractères.');
const html = await fetchSearchResults(this.baseUrl, query);
if (html.includes('Aucun résultat')) return [];
let results = parseSearchHTML(html, this.baseUrl);
if (mediaType === 'movie') {
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
} else {
results = results.filter(r => r.type === 'series' || r.type === 'anime');
}
return deduplicateByTitle(results);
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = mediaType === 'movie'
? await fetchTrendingMovies(this.baseUrl)
: await fetchTrendingSeries(this.baseUrl);
const results = parseSearchHTML(html, this.baseUrl).slice(0, 40);
return deduplicateByTitle(results).slice(0, 20);
} catch (e: any) {
console.error(`[ZT] ❌ Erreur trending ${mediaType}:`, e.message);
return [];
}
}
async getRecent(): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = await fetchRecent(this.baseUrl);
const results = parseSearchHTML(html, this.baseUrl).slice(0, 40);
return deduplicateByTitle(results).slice(0, 20);
} catch (e: any) {
console.error(`[ZT] ❌ Erreur getRecent:`, e.message);
return [];
}
}
async getContentLinks(pageUrl: string): Promise<ContentLinks> {
if (!this.baseUrl) throw new Error('ZT_URL non configurée.');
const fullUrl = pageUrl.startsWith('http') ? pageUrl : (this.baseUrl + (pageUrl.startsWith('/') ? '' : '/') + pageUrl);
const html = await fetchContentPage(fullUrl);
return parseContentHTML(html);
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
const targetUrl = seasonValue ? String(seasonValue) : identifier;
const content = await this.getContentLinks(targetUrl);
const isSeries = targetUrl.includes('/telecharger-serie/') || targetUrl.includes('/serie-') || (content.relatedSeasons?.length || 0) > 0;
let currentSeasonLabel = "Saison (Actuelle)";
if (content.releaseNames && content.releaseNames.length > 0) {
const sm = content.releaseNames[0].match(/Saison\s*\d+/i);
if (sm) currentSeasonLabel = sm[0];
}
const formattedSeasons = (content.relatedSeasons || []).map(s => ({
label: s.label,
value: s.href
}));
if (isSeries) {
formattedSeasons.push({ label: currentSeasonLabel, value: targetUrl });
formattedSeasons.sort((a, b) => {
const numA = parseInt(a.label.replace(/\D/g, '')) || 0;
const numB = parseInt(b.label.replace(/\D/g, '')) || 0;
return numA - numB;
});
}
const allLinks = [...content.links];
if (content.relatedQualities && content.relatedQualities.length > 0) {
console.log(`[ZT] Fetching ${content.relatedQualities.length} other qualities concurrently...`);
const qualityPromises = content.relatedQualities.map(async (q) => {
try {
const qContent = await this.getContentLinks(q.href);
return qContent.links;
} catch (e) {
console.error(`[ZT] Error fetching quality page ${q.href}:`, e);
return [];
}
});
const otherQualitiesLinks = await Promise.all(qualityPromises);
otherQualitiesLinks.forEach(links => allLinks.push(...links));
}
return { links: allLinks, seasons: formattedSeasons, isSeries };
}
async resolveLink(linkId: string): Promise<string | null> {
try {
console.log(`[ZT] 🔓 Résolution du lien : ${linkId}`);
const html = await fetchResolvedLink(linkId);
const resolved = extractLinkFromZtProtect(html);
if (!resolved) {
console.warn(`[ZT] ⚠️ Impossible d'extraire le lien résolu du HTML de ZTProtect pour ${linkId}`);
}
return resolved;
} catch (e: any) {
console.error(`[ZT] ❌ Erreur resolveLink pour ${linkId}:`, e.message);
return null;
}
}
}
// ── Auto-registration ──
sourceRegistry.register(new ZoneTelechargementAPI());
+191
View File
@@ -0,0 +1,191 @@
import { SearchResult, MediaType, ContentLinks, VideoLink } from '../../src/types/source.js';
/**
* Parse le HTML de résultats de recherche ZT.
*/
export function parseSearchHTML(html: string, baseUrl: string | undefined): SearchResult[] {
const results: SearchResult[] = [];
const coverRegex = /<div class="cover_global"[^>]*>([\s\S]*?)(?=<div class="cover_global"|$)/g;
let match: RegExpExecArray | null;
while ((match = coverRegex.exec(html)) !== null) {
const block = match[1]!;
const titleMatch = block.match(/<div class="cover_infos_title"[^>]*>\s*<a href="([^"]+)"[^>]*>\s*([^<]+)/);
if (!titleMatch) continue;
const href = titleMatch[1]!.trim();
const title = titleMatch[2]!.trim();
const imgMatch = block.match(/<img class="mainimg"[^>]*src="([^"]+)"/);
let image = imgMatch ? imgMatch[1]! : null;
if (image && image.startsWith('/') && baseUrl) {
image = baseUrl + image;
}
let type: 'movie' | 'series' | 'anime' = 'movie';
if (href.includes('/telecharger-serie/') || href.includes('/serie-')) {
type = 'series';
} else if (href.includes('/animes')) {
type = 'anime';
}
let year: string | null = null;
const yearMatch = title.match(/\(\s*(\d{4})\s*\)/) || href.match(/-(\d{4})-/);
if (yearMatch) {
year = yearMatch[1];
}
results.push({ title, image, hrefPath: href, year, type, source: 'zt' });
}
return results;
}
/**
* Parse le HTML d'une page de contenu ZT pour en extraire les liens et saisons.
*/
export function parseContentHTML(html: string): ContentLinks {
const links: VideoLink[] = [];
const releaseNames: string[] = [];
const releaseRegex = /<font color=red>([^<]+)<\/font>/g;
let releaseMatch: RegExpExecArray | null;
while ((releaseMatch = releaseRegex.exec(html)) !== null) {
releaseNames.push(releaseMatch[1]!.trim());
}
const sections = html.split(/<img src='\/img\/([^']+)'/);
for (let i = 1; i < sections.length; i += 2) {
const hostImg = sections[i]!;
const hostName = hostImg.replace('.png', '').replace('.jpg', '').replace('.webp', '');
const sectionHtml = sections[i + 1] || '';
const linkRegex = /<a class="btnToLink"[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>/g;
let linkMatch: RegExpExecArray | null;
while ((linkMatch = linkRegex.exec(sectionHtml)) !== null) {
const zoneursUrl = linkMatch[1]!;
const label = linkMatch[2]!.trim();
// Extraire la taille depuis le label : "NOM.FICHIER (11.5 GO)" → "11.5 GO"
const sizeRegex = /\s*\(([\d.,]+\s*(?:go|gb|mo|mb|ko|kb|to|tb))\)/i;
let sizeMatch = label.match(sizeRegex);
let size = sizeMatch ? sizeMatch[1]!.trim().toUpperCase() : undefined;
// Si non trouvé dans le label, on cherche dans le nom de la release (qualité)
if (!size && releaseNames.length > 0) {
const qualityMatch = releaseNames[0].match(sizeRegex);
if (qualityMatch) size = qualityMatch[1]!.trim().toUpperCase();
}
// Nettoyer le label pour enlever la taille
const cleanedLabel = label.replace(sizeRegex, "").trim();
// On n'utilise le label comme "épisode" que si c'est un vrai nom de fichier/épisode (pas juste "Télécharger")
const isGenericLabel = /^(t\u00e9l\u00e9charger|download|cliquez ici|lien|turbobit|1fichier|rapidgator|nitroflare|send.now)/i.test(cleanedLabel);
let episode = (!isGenericLabel && cleanedLabel.length > 3) ? cleanedLabel : undefined;
// SI le label est générique, on cherche un texte juste avant (ex: "Episode 1")
if (isGenericLabel || !episode) {
const index = linkMatch.index;
const prevHtml = sectionHtml.substring(Math.max(0, index - 100), index);
// Cherche "Episode X", "Saison complète", etc.
const epMatch = prevHtml.match(/(?:<b>|<strong>)?(Episode\s*\d+|Saison\s*compl\u00e8te)(?:<\/b>|<\/strong>)?/i);
if (epMatch) {
episode = epMatch[1].trim();
}
}
let quality = releaseNames.length > 0 ? releaseNames[0] : 'Inconnu';
if (quality.match(sizeRegex)) quality = quality.replace(sizeRegex, '');
let langs: string[] = [];
let subs: string[] = [];
const textToScan = `${quality} ${cleanedLabel}`;
const langMatch = textToScan.match(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi);
if (langMatch) {
const seenLangs = new Set<string>();
const seenSubs = new Set<string>();
langMatch.forEach(l => {
const up = l.toUpperCase();
if (up.includes('VOSTFR')) { seenLangs.add('VOSTFR'); seenSubs.add('French'); }
else if (up.includes('TRUEFRENCH')) seenLangs.add('TrueFrench');
else if (up.includes('FRENCH') || up === 'VF' || up === 'VFF') seenLangs.add('French');
else if (up.includes('MULTI')) { seenLangs.add('MULTI'); seenSubs.add('Multi'); }
});
langs = Array.from(seenLangs);
subs = Array.from(seenSubs);
quality = quality.replace(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi, '').trim();
}
quality = quality.replace(/[\(\)\[\]\-]+$/g, '').replace(/[\(\)\[\]]/g, '').replace(/\s+/g, ' ').trim();
if (!quality || quality.toLowerCase() === 'inconnu') quality = 'WEB';
links.push({
id: zoneursUrl,
host: hostName,
label: cleanedLabel,
url: null,
size,
quality: quality,
langs,
subs,
episode: episode,
});
}
}
const relatedSeasons: { href: string; label: string }[] = [];
const relatedQualities: { href: string; label: string }[] = [];
// Chercher toutes les sections "également disponibles"
const sectionRegex = /(Saisons?|Qualit(?:é|e)s?)\s*également disponibles[\s\S]*?<\/h3>([\s\S]*?)(?:<h3|<\/div>|<div[^>]*class="postinfo")/gi;
let sSectionMatch: RegExpExecArray | null;
while ((sSectionMatch = sectionRegex.exec(html)) !== null) {
const type = sSectionMatch[1].toLowerCase();
const seasonBlock = sSectionMatch[2]!;
const seasonRegex = /<a[^>]*href="([^"]+)"[^>]*><span class="otherquality">([\s\S]*?)<\/span><\/a>/g;
let sMatch: RegExpExecArray | null;
while ((sMatch = seasonRegex.exec(seasonBlock)) !== null) {
const label = sMatch[2]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
const href = sMatch[1]!.trim();
if (type.includes('saison')) {
if (!relatedSeasons.find(rs => rs.href === href)) {
relatedSeasons.push({ href, label });
}
} else {
if (!relatedQualities.find(rs => rs.href === href)) {
relatedQualities.push({ href, label });
}
}
}
}
return { links, releaseNames, relatedSeasons, relatedQualities };
}
/**
* Extrait le lien final déverrouillé de la page HTML de ZTPROTECT.
*/
export function extractLinkFromZtProtect(html: string): string | null {
// 1. Essayer de trouver la valeur de l'input result-input
let match = html.match(/class="result-input"\s+value="([^"]+)"/i);
if (match && match[1]) return match[1];
// 2. Essayer de trouver l'attribut href du bouton de succès
match = html.match(/<a\s+[^>]*href="([^"]+)"[^>]*class="[^"]*btn-success[^"]*"/i);
if (match && match[1]) return match[1];
match = html.match(/class="[^"]*btn-success[^"]*"\s+[^>]*href="([^"]+)"/i);
if (match && match[1]) return match[1];
return null;
}
+112
View File
@@ -0,0 +1,112 @@
import { SearchResult, VideoLink, SeasonOption, SelectionData, ContentLinks } from '../../src/types/source.js';
import { FlixArtAuth } from './auth.js';
import { FlixArtParser } from './parser.js';
import { CONFIG } from '../../src/utils/config.js';
export class FlixArtAPI {
private static get baseUrl() { return CONFIG.FLIXART_URL || ''; }
private static get ajaxUrl() { return `${this.baseUrl}/wp-admin/admin-ajax.php`; }
private static userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
// Cache the film page data temporarily for resolveLink
private static contextCache: { [url: string]: { postId: string, nonce: string, type: string } } = {};
private static async fetchWithAuth(url: string, options: RequestInit = {}, retries = 1): Promise<Response> {
try {
const cookie = await FlixArtAuth.getCookie();
const headers = new Headers(options.headers || {});
headers.set('User-Agent', this.userAgent);
headers.set('Cookie', cookie);
headers.set('Origin', this.baseUrl);
headers.set('Referer', this.baseUrl);
const response = await fetch(url, { ...options, headers });
// If FlixArt returns 403 or redirects to login, refresh cookie and retry
if (response.status === 403 && retries > 0) {
console.log('[FlixArt] Session expirée, renouvellement du cookie...');
await FlixArtAuth.getCookie(true);
return this.fetchWithAuth(url, options, retries - 1);
}
return response;
} catch (error) {
if (retries > 0) {
await FlixArtAuth.getCookie(true);
return this.fetchWithAuth(url, options, retries - 1);
}
throw error;
}
}
public static async search(query: string, mediaType?: string): Promise<SearchResult[]> {
const body = new URLSearchParams({
action: 'flixart_header_search',
s: query,
search: query,
type_query: 'all',
post_type: mediaType === 'series' ? 'tv_shows' : 'movies'
});
// Search works without auth, but we use fetchWithAuth just in case
const res = await this.fetchWithAuth(this.ajaxUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: body.toString()
});
const data = await res.json();
if (data.success && data.data && data.data.results) {
return FlixArtParser.parseSearchAjax(data.data.results);
}
return [];
}
public static async getTrending(mediaType: 'movie' | 'series'): Promise<SearchResult[]> {
const res = await this.fetchWithAuth(this.baseUrl, { method: 'GET' });
const html = await res.text();
return FlixArtParser.parseTrending(html, mediaType === 'series');
}
public static async getSelection(url: string): Promise<SelectionData> {
const res = await this.fetchWithAuth(url, { method: 'GET' });
const html = await res.text();
const parsed = FlixArtParser.parseSelection(html);
// Cache post data for resolveLink
if (parsed.postId && parsed.nonce) {
this.contextCache[url] = {
postId: parsed.postId,
nonce: parsed.nonce,
type: parsed.isSeries ? 'tv_shows' : 'movies' // Note: actually parser returns isSeries. Captcha needs 'movies' or 'tv_shows'
};
}
// Prefix ID with url to pass state to resolveLink
parsed.links.forEach((link: any) => {
link.id = `${url}|${link.id}`;
});
return {
links: parsed.links,
seasons: parsed.seasons,
isSeries: parsed.isSeries
};
}
public static async getContentLinks(url: string, season?: number): Promise<ContentLinks> {
// Not used heavily if getSelection is prioritized, but we need to fetch the season HTML via ajax
// For simplicity, if season is passed, we fetch season content via AJAX.
// Actually, FlixArt loads all episodes HTML when you click a season tab.
// For now, getSelection is sufficient.
const selection = await this.getSelection(url);
return { links: selection.links };
}
}
+148
View File
@@ -0,0 +1,148 @@
import { CONFIG } from '../../src/utils/config.js';
import fs from 'fs';
import path from 'path';
export class FlixArtAuth {
private static sessionCookie: string | null = null;
private static lastLoginTime: number = 0;
private static readonly COOKIE_FILE = path.resolve(process.cwd(), 'database', 'flixart_cookie.txt');
public static async getCookie(forceRefresh = false): Promise<string> {
if (!this.sessionCookie && fs.existsSync(this.COOKIE_FILE)) {
try {
const stats = fs.statSync(this.COOKIE_FILE);
// Si le cookie a moins de 7 jours, on le réutilise (le renouvellement se fera si on obtient une 403)
if (Date.now() - stats.mtimeMs < 7 * 24 * 60 * 60 * 1000) {
this.sessionCookie = fs.readFileSync(this.COOKIE_FILE, 'utf-8');
this.lastLoginTime = stats.mtimeMs;
}
} catch (e) {
console.warn('[FlixArt Auth] Impossible de lire le cookie sauvegardé:', e);
}
}
if (!forceRefresh && this.sessionCookie && Date.now() - this.lastLoginTime < 12 * 60 * 60 * 1000) {
return this.sessionCookie;
}
const username = CONFIG.FLIXART_USERNAME;
const password = CONFIG.FLIXART_PASSWORD;
const baseUrl = CONFIG.FLIXART_URL || '';
const ajaxUrl = `${baseUrl}/wp-admin/admin-ajax.php`;
if (!username || !password) {
throw new Error('[FlixArt Auth] Identifiants manquants.');
}
console.log(`[FlixArt] Tentative de connexion avec l'utilisateur: ${username}...`);
try {
// 1. Obtenir un nouveau nonce de login
const refreshParams = new URLSearchParams({
action: 'flixart_auth_refresh_nonces'
});
const refreshRes = await fetch(ajaxUrl, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'Referer': baseUrl
},
body: refreshParams.toString()
});
const refreshData = await refreshRes.json();
if (!refreshData.success || !refreshData.data || !refreshData.data.loginNonce) {
throw new Error("Impossible d'obtenir le nonce de connexion.");
}
const loginNonce = refreshData.data.loginNonce;
const refreshCookies = (refreshRes.headers.getSetCookie ? refreshRes.headers.getSetCookie() : [refreshRes.headers.get('set-cookie') || '']).map(c => c.split(';')[0]).filter(Boolean);
const refreshCookieStr = refreshCookies.join('; ');
// 2. Se connecter
const dataParams = new URLSearchParams();
dataParams.append('log', username);
dataParams.append('pwd', password);
dataParams.append('redirect', baseUrl + '/membership-account/');
const loginParams = new URLSearchParams();
loginParams.append('action', 'flixart_auth_login');
loginParams.append('nonce', loginNonce);
loginParams.append('data', dataParams.toString());
const loginRes = await fetch(ajaxUrl, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'Origin': baseUrl,
'Referer': baseUrl + '/',
'Cookie': refreshCookieStr
},
body: loginParams.toString(),
redirect: 'manual'
});
const loginBody = await loginRes.clone().json().catch(() => ({}));
if (loginBody.success === false) {
const code = loginBody.data?.code || loginBody.data?.[0]?.code;
if (code === 'too_many_devices') {
console.log(`[FlixArt] ⚠️ Limite d'appareils atteinte. Tentative de libération...`);
const recoveryParams = new URLSearchParams({
action: 'flixart_device_recovery',
nonce: refreshData.data.deviceRecoveryNonce,
username: username,
password: password
});
await fetch(ajaxUrl, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Referer': baseUrl,
'Cookie': refreshCookieStr
},
body: recoveryParams.toString()
});
console.log(`[FlixArt] ✅ Appareils libérés, nouvelle tentative de connexion...`);
return this.getCookie(true);
}
throw new Error(loginBody.data?.[0]?.message || loginBody.data?.message || 'Échec de la connexion.');
}
// FlixArt returns 200 OK with success: true and sets cookies
const setCookieHeader = loginRes.headers.get('set-cookie') || loginRes.headers.get('Set-Cookie');
let cookies: string[] = [];
if (setCookieHeader) {
const setCookieHeaders = loginRes.headers.getSetCookie ? loginRes.headers.getSetCookie() : [setCookieHeader];
cookies = setCookieHeaders.map(c => c.split(';')[0]);
}
if (!cookies.some(c => c.includes('wordpress_logged_in_'))) {
console.warn(`[FlixArt] ⚠️ Pas de cookie wordpress_logged_in trouvé.`);
throw new Error('Échec de la connexion (Pas de cookie de session complet).');
}
this.sessionCookie = cookies.join('; ');
this.lastLoginTime = Date.now();
try {
fs.writeFileSync(this.COOKIE_FILE, this.sessionCookie, 'utf-8');
} catch (e) {
console.warn('[FlixArt Auth] Impossible de sauvegarder le cookie:', e);
}
console.log(`[FlixArt] ✅ Connexion réussie ! (Cookie généré)`);
return this.sessionCookie;
} catch (error: any) {
console.error('[FlixArt] ❌ Erreur lors de la connexion:', error.message);
throw error;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import { FlixArtAuth } from './auth.js';
import fs from 'fs';
import { CONFIG } from '../../src/utils/config.js';
async function dump() {
const cookie = await FlixArtAuth.getCookie();
const baseUrl = CONFIG.FLIXART_URL || '';
const res = await fetch(`${baseUrl}/film/avatar/`, {
headers: { 'Cookie': cookie, 'User-Agent': 'Mozilla/5.0' }
});
const html = await res.text();
fs.writeFileSync('scratch/avatar.html', html);
console.log('Saved to scratch/avatar.html, length:', html.length);
}
dump();
+56
View File
@@ -0,0 +1,56 @@
import { ISource, SearchResult, SelectionData, ContentLinks, MediaType } from '../../src/types/source.js';
import { FlixArtAPI } from './api.js';
export class FlixartSource implements ISource {
name = 'flixart';
displayName = 'FlixArt';
async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
return FlixArtAPI.search(query, mediaType);
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
const type = mediaType === 'series' ? 'series' : 'movie';
return FlixArtAPI.getTrending(type);
}
async getRecent(): Promise<SearchResult[]> {
return this.getTrending('movie');
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
return FlixArtAPI.getSelection(identifier);
}
async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
return FlixArtAPI.getContentLinks(identifier, season);
}
async healthCheck(): Promise<boolean> {
try {
const results = await this.getTrending('movie');
return results.length > 0;
} catch (e: any) {
console.error(`[FlixArt] Healthcheck failed: ${e.message}`);
return false;
}
}
// Custom resolveLink that returns a Turnstile challenge instead of just the URL
// Actually, Agora's activeSource.resolveLink only accepts string.
// We will change ISource resolveLink to allow returning an object.
async resolveLink(linkId: string, extraData?: any): Promise<any> {
const [url] = linkId.split('|');
// FlixArt requires a Cloudflare Turnstile challenge which cannot be resolved on localhost.
// We directly return the manual redirection challenge.
return {
captcha: 'turnstile',
url: url,
sourceName: 'FlixArt'
};
}
}
// Auto-registration
import { sourceRegistry } from '../../src/core/registry.js';
sourceRegistry.register(new FlixartSource());
+167
View File
@@ -0,0 +1,167 @@
import { SearchResult, VideoLink, SeasonOption } from '../../src/types/source.js';
function getMediaTypeFromUrl(url: string): 'movie' | 'series' {
return url.includes('/serie/') ? 'series' : 'movie';
}
function unescapeHtml(html: string): string {
return html
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#038;/g, '&');
}
export class FlixArtParser {
static parseSearchAjax(htmlStr: string): SearchResult[] {
const results: SearchResult[] = [];
const cardRegex = /<a class="[^"]*flixart-search-card[^"]*" href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
let match;
while ((match = cardRegex.exec(htmlStr)) !== null) {
const href = unescapeHtml(match[1]);
const inner = match[2];
let title = '';
const titleMatch = inner.match(/<span class="flixart-search-result-title">([^<]+)<\/span>/);
if (titleMatch) title = unescapeHtml(titleMatch[1].trim());
let year = null;
const yearMatch = inner.match(/<span class="video-years">([^<]+)<\/span>/);
if (yearMatch) year = yearMatch[1].trim();
let image = null;
const imgMatch = inner.match(/<img[^>]+src="([^"]+)"/);
if (imgMatch) {
image = unescapeHtml(imgMatch[1]);
if (image.includes('&quality=')) image = image.split('&quality=')[0];
}
if (title && href) {
results.push({
title,
year,
image,
hrefPath: href,
type: getMediaTypeFromUrl(href),
source: 'flixart'
});
}
}
return results;
}
static parseTrending(htmlStr: string, isSeries: boolean): SearchResult[] {
const results: SearchResult[] = [];
const sectionTitle = isSeries ? 'Top 10 séries du jour' : 'Top 10 films du jour';
const fallbackTitle = isSeries ? 'Nouveautés séries' : 'Nouveautés films';
// Find section containing the title
let sectionRegexStr = `<section class="fx-section">\\s*<div class="fx-section-head">\\s*<h2>(${sectionTitle}|${fallbackTitle})<\\/h2>[\\s\\S]*?<\\/section>`;
let sectionMatch = htmlStr.match(new RegExp(sectionRegexStr, 'i'));
if (!sectionMatch) return results;
const sectionHtml = sectionMatch[0];
const cardRegex = /<article class="fx-card[^"]*">([\s\S]*?)<\/article>/g;
let match;
while ((match = cardRegex.exec(sectionHtml)) !== null) {
const inner = match[1];
let href = null;
let title = '';
const titleMatch = inner.match(/<h3 class="fx-card-title"><a href="([^"]+)"[^>]*>([^<]+)<\/a><\/h3>/);
if (titleMatch) {
href = unescapeHtml(titleMatch[1]);
title = unescapeHtml(titleMatch[2].trim());
}
let image = null;
const imgMatch = inner.match(/<img src="([^"]+)"/);
if (imgMatch) {
image = unescapeHtml(imgMatch[1]);
if (image.includes('&quality=')) image = image.split('&quality=')[0];
}
let year = null;
const yearMatch = inner.match(/<span>(\d{4})<\/span>\s*<\/span>/);
if (yearMatch) {
year = yearMatch[1];
}
if (title && href) {
results.push({
title,
year,
image,
hrefPath: href,
type: isSeries ? 'series' : 'movie',
source: 'flixart'
});
}
}
return results;
}
static parseSelection(htmlStr: string): { links: VideoLink[], seasons: SeasonOption[], isSeries: boolean, postId: string, nonce: string | null } {
const links: VideoLink[] = [];
const seasons: SeasonOption[] = [];
let isSeries = false;
let postId = '';
const postIdMatch = htmlStr.match(/data-post-id="(\d+)"/);
if (postIdMatch) postId = postIdMatch[1];
let nonce: string | null = null;
const nonceMatch = htmlStr.match(/flixartDownloadCaptcha\s*=\s*\{[^}]*nonce:\s*'([^']+)'/);
if (nonceMatch) nonce = nonceMatch[1];
const seasonTabRegex = /<button[^>]+class="[^"]*flixart-season-tab[^"]*"[^>]+data-season="([^"]+)"[^>]*>([\s\S]*?)<\/button>/g;
let match;
while ((match = seasonTabRegex.exec(htmlStr)) !== null) {
isSeries = true;
const val = match[1];
const inner = match[2];
const numMatch = inner.match(/<span class="flixart-season-tab__number">([^<]+)<\/span>/);
if (numMatch) {
seasons.push({ label: `Saison ${numMatch[1].trim()}`, value: val });
}
}
const rowRegex = /<div role="row" class="jws-lien-row[^"]*"[^>]*data-qualite="([^"]*)"[^>]*data-langue="([^"]*)"[^>]*>([\s\S]*?)<\/div>/g;
while ((match = rowRegex.exec(htmlStr)) !== null) {
const inner = match[3];
let episode = null;
const episodeMatch = htmlStr.substring(match.index - 100, match.index).match(/data-episode="([^"]+)"/);
if (episodeMatch) episode = episodeMatch[1];
const checkboxMatch = inner.match(/<input[^>]+data-flixart-download-select[^>]+data-row-index="(\d+)"[^>]*data-download-title="([^"]*)"[^>]*data-download-meta="([^"]*)"/);
if (checkboxMatch) {
const rowIndex = checkboxMatch[1];
const title = unescapeHtml(checkboxMatch[2]);
const meta = unescapeHtml(checkboxMatch[3]);
let host = 'Inconnu';
const lowerMeta = meta.toLowerCase();
if (lowerMeta.includes('1fichier')) host = '1fichier';
else if (lowerMeta.includes('nitroflare')) host = 'nitroflare';
else if (lowerMeta.includes('ddownload')) host = 'ddownload';
links.push({
id: rowIndex,
host,
label: title,
quality: meta,
url: null,
episode
});
}
}
return { links, seasons, isSeries, postId, nonce };
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Appels réseau pour free-telecharger.cam.
* Pas de challenge CF actif, fetch direct simple.
*/
const TIMEOUT = 20_000;
const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0';
async function ftGet(url: string): Promise<string> {
const res = await fetch(url, {
headers: {
'User-Agent': UA,
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
},
redirect: 'follow',
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchSearch(baseUrl: string, query: string): Promise<string> {
return ftGet(`${baseUrl}/1/recherche1/1.html?rech_fiche=${encodeURIComponent(query)}`);
}
export async function fetchTrending(baseUrl: string): Promise<string> {
return ftGet(`${baseUrl}/page/1.html`);
}
export async function fetchPage(pageUrl: string): Promise<string> {
return ftGet(pageUrl);
}
+140
View File
@@ -0,0 +1,140 @@
import { ISource, SearchResult, MediaType, SelectionData, ContentLinks } from '../../src/types/source.js';
import { CONFIG } from '../../src/utils/config.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { fetchSearch, fetchTrending, fetchPage } from './api.js';
import { parseSearchResults, parseTrendingResults, parseContentHTML, parseEpisodeLinks, parseOtherVersions } from './parser.js';
function isSeriesIdentifier(identifier: string): boolean {
return /saison|pack-series|series-(vf|vostfr|terminee)/i.test(identifier);
}
export class FreeTeleAPI implements ISource {
name = 'freetel';
displayName = 'Free-Télécharger';
get baseUrl() {
return CONFIG.FT_URL?.replace(/\/$/, '');
}
async healthCheck(): Promise<boolean> {
if (!this.baseUrl) {
console.warn('[FreeTel] ⚠️ FT_URL non définie.');
return false;
}
try {
const res = await fetch(this.baseUrl, {
method: 'HEAD',
headers: { 'User-Agent': 'Mozilla/5.0' },
signal: AbortSignal.timeout(5000),
});
return res.ok;
} catch {
return true; // tolérant : le test réel se fait au premier scrape
}
}
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
if (!query || query.length < 3) throw new Error('La recherche nécessite au moins 3 caractères.');
const html = await fetchSearch(this.baseUrl, query);
let results = parseSearchResults(html, this.baseUrl);
if (mediaType === 'movie') {
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
} else {
results = results.filter(r => r.type === 'series' || r.type === 'anime');
}
return results;
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = await fetchTrending(this.baseUrl);
let results = parseTrendingResults(html, this.baseUrl);
if (mediaType === 'movie') {
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
} else {
results = results.filter(r => r.type === 'series' || r.type === 'anime');
}
return results.slice(0, 20);
} catch (e: any) {
console.error(`[FreeTel] Erreur trending ${mediaType}:`, e.message);
return [];
}
}
async getRecent(): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = await fetchTrending(this.baseUrl);
const results = parseTrendingResults(html, this.baseUrl).slice(0, 20);
return results;
} catch (e: any) {
console.error(`[FreeTel] Erreur getRecent:`, e.message);
return [];
}
}
async getContentLinks(identifier: string): Promise<ContentLinks> {
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
const url = identifier.startsWith('http') ? identifier : `${this.baseUrl}/${identifier.replace(/^\//, '')}`;
const html = await fetchPage(url);
return parseContentHTML(html, isSeriesIdentifier(identifier));
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
// Si seasonValue est fournie (l'UI a cliqué sur une autre qualité), on switch de fiche
const targetIdentifier = seasonValue ? String(seasonValue) : identifier;
const url = targetIdentifier.startsWith('http') ? targetIdentifier : `${this.baseUrl}/${targetIdentifier.replace(/^\//, '')}`;
const html = await fetchPage(url);
const isSeries = isSeriesIdentifier(targetIdentifier);
const content = parseContentHTML(html, isSeries);
// Pour les films, exposer les autres qualités comme "seasons" (l'UI les affichera en dropdown)
let seasons: { label: string; value: string }[] = [];
if (!isSeries) {
seasons = parseOtherVersions(html, this.baseUrl);
// Ajouter la version courante comme première entrée (sélectionnée par défaut)
const currentQuality = content.links[0]?.quality;
if (currentQuality && currentQuality !== 'Inconnu') {
seasons.unshift({ label: currentQuality, value: targetIdentifier });
}
}
return {
links: content.links,
seasons,
isSeries,
};
}
async resolveLink(linkId: string): Promise<string | null> {
let hostUrl: string | null = null;
// Cas série : page intermédiaire liens.free-telecharger.cam/SLUG-episode_N
if (linkId.includes('liens.free-telecharger.')) {
try {
const html = await fetchPage(linkId);
const hosts = parseEpisodeLinks(html);
if (hosts.length === 0) {
console.warn(`[FreeTel] Aucun hôte trouvé sur ${linkId}`);
return null;
}
const preferred = hosts.find(h => /1fichier/i.test(h.host))
|| hosts.find(h => /turbobit/i.test(h.host))
|| hosts[0];
hostUrl = preferred ? preferred.url : null;
} catch (e: any) {
console.error(`[FreeTel] Erreur resolveLink:`, e.message);
return null;
}
} else if (linkId.startsWith('http')) {
// Cas film : linkId est déjà l'URL hôte (1fichier, Turbobit, …)
hostUrl = linkId;
}
return hostUrl;
}
}
sourceRegistry.register(new FreeTeleAPI());
+213
View File
@@ -0,0 +1,213 @@
import { SearchResult, ContentLinks, VideoLink } from '../../src/types/source.js';
interface FilmMetadata {
quality?: string;
size?: string;
langs?: string[];
}
function parseFilmMetadata(html: string): FilmMetadata {
const meta: FilmMetadata = {};
const q = html.match(/Qualit[ée][^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
if (q) meta.quality = q[1]!.trim();
const t = html.match(/Taille[^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
if (t) meta.size = t[1]!.trim();
const l = html.match(/Langue[^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
if (l) meta.langs = l[1]!.trim().split(/[,\/]/).map(s => s.trim()).filter(Boolean);
return meta;
}
/**
* Extrait les autres versions/qualités disponibles pour le même film.
* Section "Autres versions disponibles pour ..."
*/
export function parseOtherVersions(html: string, baseUrl: string): { label: string; value: string }[] {
const out: { label: string; value: string }[] = [];
const sectionMatch = html.match(/Autres versions disponibles[\s\S]+?<\/div>\s*<\/div>/i);
if (!sectionMatch) return out;
const linkRegex = /<a\s+href="([^"]+)"[\s\S]*?🎞️\s*([^<]+?)<\/a>/gi;
let m: RegExpExecArray | null;
while ((m = linkRegex.exec(sectionMatch[0])) !== null) {
const href = absUrl(m[1]!, baseUrl);
const label = m[2]!.replace(/\s+/g, ' ').trim();
if (!out.find(o => o.value === href)) out.push({ label, value: href });
}
return out;
}
function normalizeTitle(title: string): string {
return title
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/\b(web-?dl|web-?rip|blu-?ray|full-?blu-?ray|hdtv|hdrip|dvdrip|bdrip|hdlight|ultra-?hdlight|truefrench|french|multi(?:langues?)?|vff|vfq|vfi|vf|vostfr|english|hdts|cam|ts|r5|dvdscr|x264|x265|h\.?264|h\.?265|hevc)\b/g, '')
.replace(/\b(720p|1080p|2160p|4k|uhd|3d|sd|hd)\b/g, '')
.replace(/\(\s*\d{4}\s*\)/g, '')
.replace(/-\s*saison\s*\d+/gi, '')
.replace(/[^a-z0-9]/g, '');
}
function deduplicateByTitle<T extends { title: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter(it => {
const k = normalizeTitle(it.title);
if (!k || seen.has(k)) return false;
seen.add(k);
return true;
});
}
function detectType(href: string): 'movie' | 'series' | 'anime' {
if (/saison|pack-series|series-(vf|vostfr|terminee)/i.test(href)) return 'series';
if (/animes?/i.test(href)) return 'anime';
return 'movie';
}
function absUrl(url: string, baseUrl: string): string {
let path = url;
if (url.startsWith('http')) {
try {
const u = new URL(url);
path = u.pathname + u.search + u.hash;
} catch {
return url;
}
}
const cleanedBase = baseUrl.replace(/\/$/, '');
return cleanedBase + '/' + path.replace(/^\//, '');
}
/**
* Format résultats de recherche : <div class="image-container"><img/></div>
* puis <div class="content"><div class="titre1"><A href="...">Titre</A></div>
*/
export function parseSearchResults(html: string, baseUrl: string): SearchResult[] {
const results: SearchResult[] = [];
const blockRegex = /<div\s+class="image-container">\s*<img[^>]+src="([^"]+)"[^>]*>[\s\S]*?<div\s+class="titre1">\s*<a\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
let m: RegExpExecArray | null;
while ((m = blockRegex.exec(html)) !== null) {
const image = absUrl(m[1]!, baseUrl);
const hrefRaw = m[2]!;
const href = absUrl(hrefRaw, baseUrl);
const title = m[3]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
if (!title) continue;
let year: string | null = null;
const yearMatch = title.match(/\(\s*(\d{4})\s*\)/) || hrefRaw.match(/-(\d{4})-/);
if (yearMatch) {
year = yearMatch[1];
}
results.push({
title,
year,
image,
hrefPath: href,
type: detectType(hrefRaw),
source: 'freetel',
});
}
return deduplicateByTitle(results);
}
/**
* Format nouveautés (/page/1.html) : <a href="..." data-tip-b64="..."><img alt="Titre" src="..."/></a>
*/
export function parseTrendingResults(html: string, baseUrl: string): SearchResult[] {
const results: SearchResult[] = [];
const blockRegex = /<a\s+href="((?:films?-|saison-|pack-series|series-)[^"]+\.html)"[^>]*data-tip-b64="[^"]+"[^>]*>\s*<img\s+alt="([^"]+)"[^>]+src="([^"]+)"/gi;
let m: RegExpExecArray | null;
while ((m = blockRegex.exec(html)) !== null) {
const hrefRaw = m[1]!;
const title = m[2]!.trim();
const image = absUrl(m[3]!, baseUrl);
let year: string | null = null;
const yearMatch = title.match(/\(\s*(\d{4})\s*\)/) || hrefRaw.match(/-(\d{4})-/);
if (yearMatch) {
year = yearMatch[1];
}
results.push({
title,
year,
image,
hrefPath: absUrl(hrefRaw, baseUrl),
type: detectType(hrefRaw),
source: 'freetel',
});
}
return deduplicateByTitle(results);
}
/**
* Parse une fiche (film ou série).
* - Film : <input name="lien" value="https://turbobit.net/..."> dans la section #link, précédé d'un <p>HOST</p>
* - Série : <input name="lien" value="https://liens.free-telecharger.cam/SLUG-episode_N"> (à résoudre via resolveLink)
*/
export function parseContentHTML(html: string, isSeries: boolean): ContentLinks {
const links: VideoLink[] = [];
if (isSeries) {
const episodeRegex = /<input[^>]+name="lien"\s+value="(https?:\/\/liens\.free-telecharger\.[a-z]+\/[^"]+)"/gi;
let m: RegExpExecArray | null;
let idx = 0;
while ((m = episodeRegex.exec(html)) !== null) {
const url = m[1]!;
const epMatch = url.match(/episode_(\d+|final|complet)/i);
const episode = epMatch ? epMatch[1] : null;
links.push({
id: url,
host: 'multi',
label: episode ? `Épisode ${episode}` : `Lien ${idx + 1}`,
episode: episode || undefined,
quality: 'multi',
url: null,
});
idx++;
}
} else {
// Films : section #link contient des blocs (Host name dans <p>, URL dans <input hidden lien>)
const meta = parseFilmMetadata(html);
const sectionMatch = html.match(/<div\s+id="link"[\s\S]+/);
const sec = sectionMatch ? sectionMatch[0] : html;
const pairRegex = /<p[^>]*>\s*([A-Za-z0-9-]+)\s*<\/p>[\s\S]{0,800}?<input[^>]+name="lien"\s+value="([^"]+)"/gi;
let m: RegExpExecArray | null;
while ((m = pairRegex.exec(sec)) !== null) {
const host = m[1]!.trim();
const url = m[2]!;
if (/free-telecharger|trustzone|get-trust-zone/i.test(url)) continue;
links.push({
id: url,
host: host.toLowerCase(),
label: host,
quality: meta.quality || 'Inconnu',
size: meta.size,
langs: meta.langs,
url: url,
});
}
}
return { links };
}
/**
* Parse la page intermédiaire d'un épisode (liens.free-telecharger.cam/...).
* Structure : <table class="gridtable"> avec <tr> contenant [HOST] et <a href="URL">.
*/
export function parseEpisodeLinks(html: string): { host: string; url: string }[] {
const out: { host: string; url: string }[] = [];
const tableMatch = html.match(/<table[^>]*class="gridtable"[\s\S]*?<\/table>/i);
if (!tableMatch) return out;
const rows = tableMatch[0].match(/<tr[\s\S]*?<\/tr>/gi) || [];
for (const row of rows) {
const hostMatch = row.match(/\[([^\]]+)\]/);
const aMatch = row.match(/<a\s+[^>]*href\s*=\s*["']?([^"'\s>]+)/i);
if (hostMatch && aMatch) {
out.push({
host: hostMatch[1]!.toLowerCase().trim(),
url: aMatch[1]!.trim(),
});
}
}
return out;
}
+129
View File
@@ -0,0 +1,129 @@
import { FS24Auth } from './auth.js';
import { CONFIG } from '../../src/utils/config.js';
export class FS24API {
private static get baseUrl(): string {
return CONFIG.FS24_URL;
}
/**
* Recherche AJAX via /engine/ajax/search.php
*/
public static async fetchSearch(query: string, page: number = 1): Promise<string> {
const cookie = await FS24Auth.getCookie();
const searchUrl = `${this.baseUrl}/engine/ajax/search.php`;
const params = new URLSearchParams();
params.append('query', query);
params.append('page', page.toString());
console.log(`[FS24] Recherche: "${query}" (page ${page})`);
const response = await fetch(searchUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Cookie': cookie,
'X-Requested-With': 'XMLHttpRequest'
},
body: params.toString()
});
if (!response.ok) {
throw new Error(`HTTP Error ${response.status}`);
}
return await response.text();
}
/**
* Récupère la page HTML d'un contenu pour extraire le news_id
*/
public static async fetchPage(pathOrUrl: string): Promise<string> {
const cookie = await FS24Auth.getCookie();
const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${this.baseUrl}${pathOrUrl.startsWith('/') ? '' : '/'}${pathOrUrl}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Cookie': cookie
}
});
if (!response.ok) {
throw new Error(`HTTP Error ${response.status}`);
}
return await response.text();
}
/**
* Récupère la page des tendances (films ou séries)
*/
public static async fetchTrending(mediaType: 'movie' | 'series'): Promise<string> {
const cookie = await FS24Auth.getCookie();
const url = mediaType === 'series' ? `${this.baseUrl}/s-tv/` : `${this.baseUrl}/films/`;
console.log(`[FS24] Chargement des tendances ${mediaType}`);
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Cookie': cookie
}
});
if (!response.ok) throw new Error(`HTTP Error ${response.status}`);
return await response.text();
}
/**
* Récupère la page des ajouts récents
*/
public static async fetchRecent(): Promise<string> {
const cookie = await FS24Auth.getCookie();
const url = `${this.baseUrl}/film-commu/`;
console.log(`[FS24] Chargement des ajouts récents`);
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Cookie': cookie
}
});
if (!response.ok) throw new Error(`HTTP Error ${response.status}`);
return await response.text();
}
/**
* Appelle l'API JSON /engine/ajax/release-api.php pour récupérer les releases communautaires.
* C'est ici que se trouvent les vrais liens de téléchargement (fsprotect encodés en base64).
*/
public static async fetchReleases(newsId: string): Promise<any> {
const cookie = await FS24Auth.getCookie();
const url = `${this.baseUrl}/engine/ajax/release-api.php?action=release_list&post_id=${newsId}`;
console.log(`[FS24] Chargement des releases pour post_id=${newsId}`);
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Cookie': cookie,
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`HTTP Error ${response.status}`);
}
return await response.json();
}
}
+58
View File
@@ -0,0 +1,58 @@
import { CONFIG } from '../../src/utils/config.js';
export class FS24Auth {
private static sessionCookie: string | null = null;
private static lastLoginTime: number = 0;
public static async getCookie(forceRefresh = false): Promise<string> {
// If we already have a cookie and it's less than 12 hours old, return it
if (!forceRefresh && this.sessionCookie && Date.now() - this.lastLoginTime < 12 * 60 * 60 * 1000) {
return this.sessionCookie;
}
const username = CONFIG.FS24_USERNAME;
const password = CONFIG.FS24_PASSWORD;
const baseUrl = CONFIG.FS24_URL;
if (!username || !password) {
throw new Error('[FS24 Auth] Identifiants manquants.');
}
console.log(`[FS24] Tentative de connexion avec l'utilisateur: ${username}...`);
try {
const params = new URLSearchParams();
params.append('login_name', username);
params.append('login_password', password);
params.append('login', 'submit');
const response = await fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': baseUrl
},
body: params.toString(),
redirect: 'manual' // Capture the set-cookie from the redirect
});
// Collect cookies from the response headers
const setCookieHeader = response.headers.get('set-cookie');
if (setCookieHeader) {
// Parse DLE / PHP session cookies
const cookies = setCookieHeader.split(',').map(c => c.split(';')[0].trim());
this.sessionCookie = cookies.join('; ');
this.lastLoginTime = Date.now();
console.log(`[FS24] ✅ Connexion réussie ! (Cookie généré)`);
return this.sessionCookie;
} else {
console.warn(`[FS24] ⚠️ Pas de header set-cookie retourné. Les identifiants sont-ils valides ?`);
throw new Error('Échec de la connexion (Pas de cookie de session).');
}
} catch (error: any) {
console.error('[FS24] ❌ Erreur lors de la connexion:', error.message);
throw error;
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import { ISource, SearchResult, ContentLinks, MediaType, SelectionData } from '../../src/types/source.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { FS24API } from './api.js';
import { FS24Auth } from './auth.js';
import { parseListingHTML, extractNewsId, parseReleasesJSON } from './parser.js';
export class FS24Source implements ISource {
public readonly name = 'fs24';
public readonly displayName = 'FS24';
public async healthCheck(): Promise<boolean> {
try {
await FS24Auth.getCookie(true);
return true;
} catch (e: any) {
console.error(`[FS24] HealthCheck échoué: ${e.message}`);
return false;
}
}
public async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
if (!query || query.length < 3) return [];
try {
const html = await FS24API.fetchSearch(query);
const results = parseListingHTML(html, mediaType || 'movie');
return results;
} catch (e: any) {
console.error(`[FS24] Erreur search: ${e.message}`);
return [];
}
}
public async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
try {
const html = await FS24API.fetchTrending(mediaType === 'series' ? 'series' : 'movie');
const results = parseListingHTML(html, mediaType);
return results.slice(0, 20); // Keep top 20
} catch (e: any) {
console.error(`[FS24] Erreur trending: ${e.message}`);
return [];
}
}
public async getRecent(): Promise<SearchResult[]> {
try {
const html = await FS24API.fetchRecent();
const results = parseListingHTML(html, 'movie'); // Default to movie for recents, TMDB will fix it if needed
return results.slice(0, 20);
} catch (e: any) {
console.error(`[FS24] Erreur recent: ${e.message}`);
return [];
}
}
public async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
try {
// Step 1: Fetch the page HTML to extract the news_id
const html = await FS24API.fetchPage(identifier);
const newsId = extractNewsId(html);
if (!newsId) {
console.warn(`[FS24] Impossible d'extraire le news_id depuis: ${identifier}`);
return { links: [] };
}
// Step 2: Call the release JSON API to get the actual download links
const releaseData = await FS24API.fetchReleases(newsId);
const links = parseReleasesJSON(releaseData);
console.log(`[FS24] ${links.length} lien(s) trouvé(s) pour post_id=${newsId}`);
return { links };
} catch (e: any) {
console.error(`[FS24] Erreur getContentLinks: ${e.message}`);
return { links: [] };
}
}
public async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
const content = await this.getContentLinks(identifier);
return {
links: content.links,
seasons: [],
isSeries: type === 'series'
};
}
}
// ── Auto-registration ──
sourceRegistry.register(new FS24Source());
+164
View File
@@ -0,0 +1,164 @@
import { SearchResult, VideoLink, MediaType } from '../../src/types/source.js';
/**
* Parse le HTML AJAX de résultats de recherche ou pages catégories FS24.
* Supporte les blocs `.search-item` et `.short`
*/
export function parseListingHTML(html: string, mediaType: MediaType): SearchResult[] {
const results: SearchResult[] = [];
// 1. Matches pour les blocs de recherche AJAX (.search-item)
const searchRegex = /<div class=['"]search-item['"][^>]*onclick="location\.href='([^']+)'"[^>]*>([\s\S]*?)(?=<div class=['"]search-item['"]|$)/g;
let match: RegExpExecArray | null;
while ((match = searchRegex.exec(html)) !== null) {
const hrefPath = match[1]!;
const block = match[2]!;
const imgMatch = block.match(/<img\s[^>]*src=['"]([^'"]+)['"]/);
const image = imgMatch ? imgMatch[1]! : null;
const titleMatch = block.match(/<div class=['"]search-title['"]>([^<]+)<\/div>/);
if (!titleMatch) continue;
let titleRaw = titleMatch[1]!.trim();
let year: string | null = null;
const yearMatch = titleRaw.match(/\((\d{4})\)/);
if (yearMatch) {
year = yearMatch[1]!;
titleRaw = titleRaw.replace(/\s*\(\d{4}\)\s*/, '').trim();
}
if (titleRaw && hrefPath) {
results.push({ title: titleRaw, year, image, hrefPath, type: mediaType, source: 'fs24' });
}
}
// 2. Matches pour les pages régulières DLE (.short)
const shortRegex = /<div class=['"]short['"]>([\s\S]*?)<\/div>\s*<!-- \/short -->|<div class=['"]short['"]>([\s\S]*?)(?=<div class=['"]short['"]|$)/g;
while ((match = shortRegex.exec(html)) !== null) {
const block = match[1] || match[2];
if (!block) continue;
// Extract poster
const imgMatch = block.match(/<img\s[^>]*src=['"]([^'"]+)['"]/);
const image = imgMatch ? imgMatch[1]! : null;
// Extract title
const titleMatch = block.match(/<div class=['"]short-title['"]>([^<]+)<\/div>/);
if (!titleMatch) continue;
let titleRaw = titleMatch[1]!.trim();
// Extract link
const linkMatch = block.match(/<a class=['"]short-poster[^>]*href=['"]([^'"]+)['"]/);
let hrefPath = linkMatch ? linkMatch[1]! : null;
if (!hrefPath) continue;
// Remove domain if the link is absolute to keep paths source-agnostic
if (hrefPath.startsWith('http')) {
try {
const u = new URL(hrefPath);
hrefPath = u.pathname + u.search;
} catch { /* ignore */ }
}
let year: string | null = null;
const yearMatch = titleRaw.match(/\((\d{4})\)/);
if (yearMatch) {
year = yearMatch[1]!;
titleRaw = titleRaw.replace(/\s*\(\d{4}\)\s*/, '').trim();
}
if (titleRaw && hrefPath) {
results.push({ title: titleRaw, year, image, hrefPath, type: mediaType, source: 'fs24' });
}
}
return results;
}
/**
* Extrait le news_id depuis la page HTML (attribut data-news-id du bloc commu-releases-block).
*/
export function extractNewsId(html: string): string | null {
const match = html.match(/data-news-id="(\d+)"/);
return match ? match[1]! : null;
}
/**
* Décode un lien fsprotect double-Base64 en URL finale.
* Format: base64 "url:<second_b64>|metadata|timestamp|hash"
* second_b64 URL finale (ex: https://1fichier.com/...)
*/
export function decodeFsProtectLink(rawHref: string): string | null {
try {
// Extract the ?t= parameter
const tParamMatch = rawHref.match(/[?&]t=([^&]+)/);
if (!tParamMatch) return null;
const base64t = tParamMatch[1]!;
// First Base64 decode
const decodedT = Buffer.from(base64t, 'base64').toString('utf-8');
// Format: url:<second_base64>|<metadata>|<timestamp>|<hash>
if (!decodedT.startsWith('url:')) return null;
const firstPart = decodedT.substring(4).split('|')[0]!;
if (!firstPart) return null;
// Second Base64 decode → final URL
return Buffer.from(firstPart, 'base64').toString('utf-8');
} catch (e: any) {
console.error('[FS24] Erreur décodage lien Base64:', e.message);
return null;
}
}
function formatBytes(bytes: number): string {
if (!bytes || bytes <= 0) return '';
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(2) + ' GB';
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
}
/**
* Parse la réponse JSON de l'API release-api.php en VideoLink[].
*/
export function parseReleasesJSON(data: any): VideoLink[] {
const links: VideoLink[] = [];
if (!data || !data.ok || !Array.isArray(data.items)) return links;
for (const item of data.items) {
const rawLink = item.original_link || '';
const finalUrl = decodeFsProtectLink(rawLink);
if (!finalUrl) continue;
const releaseName = item.release_name || 'Inconnu';
const lowerName = releaseName.toLowerCase();
// Detect language from release name
const langs: string[] = [];
if (lowerName.includes('multi')) langs.push('vf', 'vostfr');
else if (lowerName.includes('vostfr')) langs.push('vostfr');
else if (lowerName.includes('truefrench') || lowerName.includes('french')) langs.push('vf');
else langs.push('vf');
// Detect host from URL
let host = 'Inconnu';
try {
const urlObj = new URL(finalUrl);
host = urlObj.hostname.replace('www.', '');
} catch { /* ignore */ }
links.push({
id: String(item.id),
host,
url: finalUrl,
quality: item.quality || '',
size: formatBytes(item.size_bytes),
releaseName: item.is_team ? `[TEAM] ${releaseName}` : releaseName,
langs
});
}
return links;
}
+195
View File
@@ -0,0 +1,195 @@
import { CONFIG } from '../../src/utils/config.js';
export const CONFIG_HYDRACKER = {
get BASE_URL() { return (CONFIG.HYDRACKER_URL || '').replace(/\/$/, ''); },
get API_KEY() { return CONFIG.HYDRACKER_API_KEY; },
get TIMEOUT() { return CONFIG.HYDRACKER_TIMEOUT || 15000; },
};
export function getHydrackerHeaders() {
return {
'Accept': 'application/json',
'Authorization': `Bearer ${CONFIG_HYDRACKER.API_KEY}`,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
};
}
async function fetchWithRetry(
url: string,
options: RequestInit = {},
maxRetries: number = 2,
initialDelay: number = 2000
): Promise<Response> {
let attempt = 0;
let delay = initialDelay;
while (true) {
attempt++;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG_HYDRACKER.TIMEOUT);
try {
const res = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.status === 502 || res.status === 503 || res.status === 504 || res.status === 429) {
if (attempt < maxRetries) {
console.warn(`[Hydracker-API] Attempt ${attempt}/${maxRetries} returned HTTP ${res.status} on fetch. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2;
continue;
}
}
return res;
} catch (err: any) {
clearTimeout(timeoutId);
const isTimeout = err.name === 'AbortError' || err.message?.includes('aborted');
if (attempt < maxRetries) {
const waitTime = isTimeout ? 1000 : delay;
console.warn(`[Hydracker-API] Attempt ${attempt}/${maxRetries} failed/timed out (${err.message}). Retrying in ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
if (!isTimeout) delay *= 2;
continue;
}
throw err;
}
}
}
export async function apiGet(urlPath: string, params: Record<string, any> = {}) {
let qs = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
// FIX: Hydracker API returns 401 if ':' is URL-encoded as '%3A'
qs = qs.replace(/%3A/g, ':');
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/${urlPath}` + (qs ? `?${qs}` : '');
try {
const res = await fetchWithRetry(url, {
headers: getHydrackerHeaders()
});
if (!res.ok) {
console.error(`[Hydracker-API] apiGet HTTP ${res.status} on ${urlPath}`);
return null;
}
return await res.json();
} catch (e: any) {
console.error(`[Hydracker-API] apiGet Error on ${urlPath}:`, e.message);
return null;
}
}
export async function apiPost(urlPath: string, body: any = {}) {
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/${urlPath}`;
try {
const res = await fetchWithRetry(url, {
method: 'POST',
headers: { ...getHydrackerHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return { status: res.status, body: await res.text() };
} catch (e: any) {
console.error(`[Hydracker-API] apiPost Error on ${urlPath}:`, e.message);
return null;
}
}
export async function fetchSearch(query: string) {
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/titles?query=${encodeURIComponent(query)}`;
try {
const res = await fetchWithRetry(url, {
headers: getHydrackerHeaders()
});
if (!res.ok) {
console.error(`[Hydracker-API] Search HTTP ${res.status} for "${query}"`);
return null;
}
const data = await res.json();
// Transform the new API structure to match the old expected structure
if (data && data.pagination && Array.isArray(data.pagination.data)) {
return { results: data.pagination.data };
}
return data;
} catch (e: any) {
console.error('[Hydracker-API] Search failed:', e.message);
return null;
}
}
export async function fetchMovieLinks(titleId: string) {
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/titles/${titleId}/download`;
try {
const res = await fetchWithRetry(url, {
headers: getHydrackerHeaders()
});
if (!res.ok) return null;
return await res.json();
} catch (e: any) {
return null;
}
}
/**
* Récupère la page de download d'un titre.
* - Films : GET /titles/{id}/download
* - Séries : GET /titles/{id}/season/{s}/episode/{e}/download
*
* Retourne l'objet complet contenant: video, alternative_videos, title.seasons, last_episode, etc.
*/
export async function fetchDownloadPage(titleId: string, season?: number, episode?: number) {
let urlPath: string;
if (season && season > 0 && episode && episode > 0) {
urlPath = `titles/${titleId}/season/${season}/episode/${episode}/download`;
} else if (season && season > 0) {
// On demande le premier épisode de la saison pour obtenir les métadonnées
urlPath = `titles/${titleId}/season/${season}/episode/1/download`;
} else {
urlPath = `titles/${titleId}/download`;
}
return await apiGet(urlPath);
}
/**
* Récupère TOUS les liens d'une saison en itérant sur chaque épisode via /download.
* Utilise last_episode pour savoir combien d'épisodes ont des liens.
*/
export async function fetchSeriesLiens(titleId: string, season: number = 1) {
// D'abord, obtenir les métadonnées pour savoir combien d'épisodes il y a
const firstPage = await fetchDownloadPage(titleId, season, 1);
if (!firstPage) return [];
const lastEpisodeMap = firstPage.last_episode || {};
const lastEp = lastEpisodeMap[String(season)] || 0;
if (lastEp === 0) return [];
// Collecter les liens de tous les épisodes
const allLiens: any[] = [];
// Extraire les liens du premier épisode qu'on a déjà chargé
const extractLiens = (downloadData: any) => {
const liens: any[] = [];
if (downloadData.video) liens.push(downloadData.video);
if (downloadData.alternative_videos) {
for (const av of downloadData.alternative_videos) {
// Éviter les doublons (video est souvent dans alternative_videos aussi)
if (!liens.find(l => l.id === av.id)) {
liens.push(av);
}
}
}
return liens;
};
allLiens.push(...extractLiens(firstPage));
// Charger les épisodes suivants (2 à lastEp)
for (let ep = 2; ep <= lastEp; ep++) {
const epData = await fetchDownloadPage(titleId, season, ep);
if (epData) {
allLiens.push(...extractLiens(epData));
}
}
return allLiens;
}
+284
View File
@@ -0,0 +1,284 @@
import { ISource, SearchResult, MediaType, ContentLinks, VideoLink, SelectionData } from '../../src/types/source.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { CONFIG_HYDRACKER, apiGet, apiPost, fetchSearch, fetchDownloadPage, fetchSeriesLiens } from './api.js';
import {
QUALITY_MAP, formatSize,
parseSearchResults, parseTrendingResults,
parseMovieLinks, parseSeasons, parsePremiumLink,
getLangs, getSubs
} from './parser.js';
export class HydrackerAPI implements ISource {
name = 'hydracker';
displayName = 'Hydracker (Token)';
async healthCheck(): Promise<boolean> {
console.warn('[Hydracker] ⚠️ Plugin désactivé (Site fermé définitivement). Conservé pour archivage.');
return false;
}
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
const data = await fetchSearch(query);
if (!data) {
console.error('[Hydracker] search: fetchSearch a retourné null pour', query);
return [];
}
const totalRaw = (data.results || []).length;
const parsed = parseSearchResults(data, mediaType);
console.log(`[Hydracker] search "${query}" (${mediaType}): ${totalRaw} résultats bruts → ${parsed.length} après filtre`);
return parsed;
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
// Channel 12 = Films, Channel 10 = Séries
const channelId = mediaType === 'series' ? 10 : 12;
try {
const data = await apiGet(`channel/${channelId}`, {
restriction: '',
order: 'trending:desc',
filters: '',
page: 1,
paginate: 'lengthAware',
returnContentOnly: true
});
return parseTrendingResults(data);
} catch (e: any) {
console.error(`[Hydracker] getTrending Error for channel ${channelId}:`, e.message);
return [];
}
}
async getRecent(): Promise<SearchResult[]> {
try {
const data = await apiGet('titles', { order: 'created_at:desc', page: 1, paginate: 'lengthAware' });
return parseTrendingResults(data);
} catch (e: any) {
console.error(`[Hydracker] getRecent Error:`, e.message);
return [];
}
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
// Récupérer les infos du titre via /download pour avoir les saisons
const titleData = await fetchDownloadPage(identifier);
let isSeries = false;
if (type) {
isSeries = (type === 'series' || type === 'serie' || type === 'tv');
} else if (titleData && titleData.title) {
isSeries = titleData.title.is_series === true;
}
// Extraire les saisons depuis la réponse /download
const seasonsList: number[] = [];
if (titleData && titleData.title && titleData.title.seasons) {
const seasons = titleData.title.seasons;
for (const s of seasons) {
if (typeof s.number === 'number' && s.number > 0) {
seasonsList.push(s.number);
}
}
seasonsList.sort((a, b) => a - b);
}
if (seasonsList.length > 0) isSeries = true;
const currentSeason = seasonValue ? parseInt(String(seasonValue), 10) : (isSeries ? 1 : 0);
const content = await this.getContentLinks(identifier, currentSeason);
const formattedSeasons = seasonsList.map(num => ({ label: `Saison ${num}`, value: num }));
return {
links: content.links,
seasons: isSeries ? formattedSeasons : [],
isSeries
};
}
async getContentLinks(titleId: string, season: number = 1): Promise<ContentLinks> {
if (season === 0) {
// Film : utiliser /download directement
const downloadData = await fetchDownloadPage(titleId);
if (!downloadData) return { links: [] };
return { links: this.parseLiensFromDownload(downloadData, season) };
}
// Série : itérer sur les épisodes
const rawLiens = await fetchSeriesLiens(titleId, season);
const links: VideoLink[] = rawLiens.map(l => this.parseSingleLien(l, season));
return { links };
}
/**
* Parse les liens depuis une réponse /download (film ou épisode unique)
*/
private parseLiensFromDownload(downloadData: any, season: number): VideoLink[] {
const allLiens: any[] = [];
if (downloadData.video) allLiens.push(downloadData.video);
if (downloadData.alternative_videos) {
for (const av of downloadData.alternative_videos) {
if (!allLiens.find(l => l.id === av.id)) {
allLiens.push(av);
}
}
}
return allLiens.map(l => this.parseSingleLien(l, season));
}
/**
* Convertit un objet lien brut de l'API en VideoLink unifié
*/
private parseSingleLien(l: any, season: number): VideoLink {
// Extraire le nom du host
const hostName = l.host_compact?.name || l.host?.name || l.name || '?';
// Extraire la qualité
const quality = l.qual?.qual || l.quality || QUALITY_MAP[l.qualite] || `id:${l.qualite}`;
// Extraire les langues
const langs = l.langues
? l.langues.map((la: any) => la.lang || la.name || '')
: getLangs(l);
// Extraire les sous-titres
const subs = l.subs_compact
? l.subs_compact.map((s: any) => s.name || '')
: getSubs(l);
return {
id: l.id,
host: hostName,
size: formatSize(l.taille),
sizeBytes: l.taille || 0,
quality,
langs,
subs,
releaseName: l.release || l.filename || l.name || l.titre || l.titre_release || undefined,
episode: (l.episode === 0 || l.episode === "0" || l.episode === "00" || l.episode === null)
? (season === 0 ? 'Film complet' : 'Saison complète')
: (l.episode ? String(l.episode) : null),
url: null
};
}
async getSeasons(titleId: string): Promise<number[]> {
// Utiliser /download pour récupérer les saisons (au lieu de /titles/{id} qui est redondant)
const downloadData = await fetchDownloadPage(titleId);
if (!downloadData || !downloadData.title || !downloadData.title.seasons) return [];
return downloadData.title.seasons
.map((s: any) => s.number)
.filter((n: any) => typeof n === 'number' && n > 0)
.sort((a: number, b: number) => a - b);
}
private isPremiumCache: boolean | null = null;
private premiumCheckPromise: Promise<boolean> | null = null;
async checkPremiumStatus(): Promise<boolean> {
if (this.isPremiumCache !== null) return this.isPremiumCache;
if (this.premiumCheckPromise) return this.premiumCheckPromise;
this.premiumCheckPromise = (async () => {
try {
const result = await apiGet('users/me');
if (result && result.user) {
this.isPremiumCache = !!result.user.IsPremium;
console.log(`[Hydracker] Statut Premium vérifié: ${this.isPremiumCache ? 'OUI' : 'NON'}`);
return this.isPremiumCache;
}
} catch (e: any) {
console.error('[Hydracker] Erreur vérification Premium:', e.message);
}
return false;
})();
return await this.premiumCheckPromise;
}
async resolveLink(linkId: string): Promise<string | null> {
// Tentative de résolution via la base locale d'abord
const localDbSource = sourceRegistry.get('localdb') as any;
if (localDbSource && typeof localDbSource.resolveLocalLink === 'function') {
const localUrl = localDbSource.resolveLocalLink(linkId);
if (localUrl) {
console.log(`[Hydracker] Lien résolu via base de données locale (ID: ${linkId})`);
return localUrl;
}
}
// Tenter la résolution via l'API /content/liens/{id}
try {
const result = await apiGet(`content/liens/${linkId}`);
if (result && (result.directDL || result.url || result.link)) {
const finalUrl = result.directDL || result.url || result.link;
console.log(`[Hydracker] Got final URL via API: ${finalUrl.substring(0, 80)}...`);
return finalUrl;
}
// Vérifier aussi dans result.lien (format alternatif)
if (result && result.lien && result.lien.lien) {
console.log(`[Hydracker] Got final URL via result.lien.lien`);
return result.lien.lien;
}
} catch (e: any) {
console.error(`[Hydracker] Exception resolving lien ${linkId}:`, e.message);
}
console.log(`[Hydracker] Échec de la résolution API. Fallback automatique via Movix...`);
return await this.resolveMovixLink(linkId);
}
async resolveMovixLink(lienId: string, titleId?: string): Promise<string | null> {
try {
const { CONFIG } = await import('../../src/utils/config.js');
const movixBase = CONFIG.MOVIX_URL || '';
if (!movixBase) {
console.warn('[Hydracker] MOVIX_URL non configurée, impossible de résoudre via Movix.');
return null;
}
const movixApiBase = (() => {
try {
const u = new URL(movixBase);
return `${u.protocol}//api.${u.host}/api`;
} catch { return ''; }
})();
if (!movixApiBase) return null;
console.log(`[Hydracker] Tentative de débridage Movix pour le lien ${lienId}...`);
const url = `${movixApiBase}/darkiworld/decode/${lienId}${titleId ? `?title_id=${titleId}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json, text/plain, */*',
'Referer': `${movixBase}/`,
'Origin': movixBase,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 OPR/133.0.0.0'
}
});
const data = await response.json();
if (!response.ok || data.success === false) {
console.error('[Hydracker] Erreur API Movix:', data.error || 'Erreur inconnue');
return null;
}
const directUrl = data.directDL || data.direct_url ||
(data.embed_url && (data.embed_url.directDL || data.embed_url.src || data.embed_url.lien));
if (directUrl) {
console.log(`[Hydracker] Movix a résolu le lien avec succès !`);
return directUrl;
}
return null;
} catch (e: any) {
console.error(`[Hydracker] Exception lors de la résolution Movix :`, e.message);
return null;
}
}
}
// ── Auto-registration ──
sourceRegistry.register(new HydrackerAPI());
+167
View File
@@ -0,0 +1,167 @@
import { SearchResult, MediaType, VideoLink } from '../../src/types/source.js';
export const QUALITY_MAP: Record<number, string> = {
89: "REMUX UHD", 57: "REMUX BLURAY", 92: "REMUX DVD",
17: "Blu-Ray 1080p", 76: "Blu-Ray 1080p (x265)", 16: "Blu-Ray 720p", 18: "Blu-Ray 3D",
52: "HD 1080p", 31: "HD 720p",
50: "HDLight 1080p", 86: "HDLight 1080p (x265)", 49: "HDLight 720p",
60: "Ultra HDLight (x265)", 53: "ULTRA HD (x265)",
55: "WEB 1080p", 83: "WEB 1080p (x265)", 94: "WEB 1080p Light", 54: "WEB 720p", 4: "WEB",
62: "HDTV 1080p", 61: "HDTV 720p", 14: "HDTV",
15: "HDRip", 1: "DVDRIP", 51: "DVDRIP MKV",
13: "ISO", 12: "IMG", 10: "DVD-R", 11: "Full-DVD",
};
export const LANGUAGE_MAP: Record<number, string> = {
1: "MULTI", 2: "Arab", 3: "Bengali", 4: "Chinese", 5: "English", 6: "French", 7: "French (Canada)",
8: "TrueFrench", 9: "German", 10: "Hindi", 11: "Italian", 12: "Japanese", 13: "Korean",
14: "Mandarin", 15: "Portuguese", 16: "Russian", 17: "Spanish", 18: "Turkish", 19: "unknown",
23: "Danish", 28: "Finnish", 33: "Swedish", 35: "Bulgarian", 40: "Dutch", 41: "Persian",
42: "Indonesian", 43: "Hebrew", 44: "Thai", 49: "Czech", 53: "Albanian", 57: "Greek",
61: "Hungarian", 65: "Malaysian", 66: "Norwegian", 68: "Polish", 71: "Lithuanian",
78: "Croatian", 84: "Malay", 90: "Romanian", 96: "Ukrainian", 102: "Vietnamese",
105: "Sámegiella", 106: "Muet", 108: "Georgian", 110: "Nigerian", 113: "Maasai",
117: "Estonian", 120: "Serbian", 123: "Slovak", 124: "Slovenian", 125: "Amharic",
126: "Belarusian", 127: "Bosnian", 128: "Burmese", 129: "Dzongkha", 137: "Icelandic",
138: "Kazakh", 139: "Kurdish", 140: "Latin", 141: "Latvian", 142: "Macedonian", 143: "Maori",
144: "Mongolian", 145: "Norwegian Bokmål", 146: "Serbo-Croatian", 148: "Tagalog", 149: "Tibetan",
150: "Walloon", 151: "Wolof", 152: "Yoruba", 154: "Moore", 155: "Quechuan", 156: "Rwanda",
160: "Filipino", 161: "VO", 165: "Afrikaans", 171: "Créole", 174: "Gujarati", 175: "Cantonese",
177: "FRENCH AD"
};
export const SUB_MAP: Record<number, string> = {
1: "Arab", 2: "Bengali", 3: "Chinese", 4: "English", 5: "French", 6: "German", 7: "Hindi",
8: "Italian", 9: "Japanese", 10: "Korean", 11: "Mandarin", 12: "Portuguese", 13: "Russian",
14: "Spanish", 15: "Turkish", 16: "Inconnu", 17: "Multi", 23: "Danish", 28: "Finnish",
33: "Swedish", 35: "Bulgare", 36: "Persian", 37: "Hebrew", 40: "Dutch", 42: "Indonesian",
50: "Thai", 53: "Greek", 61: "Hungarian", 65: "Malaysian", 66: "Norwegian", 68: "Polish",
71: "Lithuanian", 76: "Czech", 82: "Croatian", 88: "Malay", 94: "Romanian", 100: "Ukrainian",
106: "Vietnamese", 112: "Sámegiella", 115: "Estonian", 120: "Serbian", 123: "Slovak",
127: "Slovenian", 128: "Afrikaans", 129: "Albanian", 130: "Amharic", 131: "Armenian",
132: "Azerbaijani", 133: "Basque", 134: "Belarusian", 135: "Bosnian", 136: "Catalan",
137: "Cebuano", 138: "Chichewa", 139: "Corsican", 140: "Esperanto", 141: "Frisian",
142: "Galician", 143: "Georgian", 144: "Gujarati", 145: "Haitian Creole", 146: "Hausa",
147: "Hawaiian", 148: "Icelandic", 149: "Igbo", 150: "Irish", 151: "Javanese", 152: "Kannada",
153: "Kazakh", 154: "Khmer", 155: "Kurdish", 156: "Kyrgyz", 157: "Lao", 158: "Latin",
159: "Latvian", 160: "Luxembourgish", 161: "Macedonian", 162: "Malagasy", 163: "Maltese",
164: "Maori", 165: "Marathi", 166: "Mongolian", 167: "Myanmar", 168: "Nepali", 169: "Pashto",
170: "Punjabi", 171: "Sindhi", 172: "Sinhala", 173: "Somali", 174: "Swahili", 175: "Tajik",
176: "Tamil", 177: "Telugu", 178: "Uzbek", 179: "Welsh", 180: "Xhosa", 181: "Yiddish",
182: "Yoruba", 183: "Zulu", 184: "Filipino"
};
export function getLangs(l: any): string[] {
if (l.langues_compact && l.langues_compact.length) {
return l.langues_compact.map((la: any) => la.name || '');
}
if (l.langues && Array.isArray(l.langues)) {
return l.langues.map((id: any) => LANGUAGE_MAP[id] || '');
}
return [];
}
export function getSubs(l: any): string[] {
if (l.subs_compact && l.subs_compact.length) {
return l.subs_compact.map((la: any) => la.name || '');
}
if (l.subs && Array.isArray(l.subs)) {
return l.subs.map((id: any) => SUB_MAP[id] || '');
}
return [];
}
export function formatSize(bytes: number): string {
if (!bytes || bytes === 0) return 'N/A';
const gb = bytes / (1024 ** 3);
if (gb >= 1) return `${gb.toFixed(2)} Go`;
const mb = bytes / (1024 ** 2);
return `${mb.toFixed(0)} Mo`;
}
export function parseSearchResults(data: any, mediaType: MediaType): SearchResult[] {
const EXCLUDED_TYPES = ['games', 'music', 'app', 'ebook', 'emulation'];
// Accepte à la fois les entrées avec model_type === 'title' et celles sans ce champ
const results = (data.results || []).filter((r: any) =>
(!r.model_type || r.model_type === 'title') &&
!EXCLUDED_TYPES.includes((r.type || '').toLowerCase())
);
const filtered = results.filter((r: any) => {
const rType = (r.type || (r.is_series ? 'series' : 'movie')).toLowerCase();
if (mediaType === 'movie') {
return rType === 'movie' || rType === 'animes' || rType === 'anime' || rType === 'doc' || rType === 'other';
}
// Pour les séries
return rType === 'series' || rType === 'serie' || rType === 'animes' || rType === 'anime' || rType === 'doc' || rType === 'other';
});
return filtered.map((r: any) => ({
title: r.name,
year: r.year || (r.release_date ? r.release_date.substring(0, 4) : 'N/A'),
image: r.poster || r.image || null,
hrefPath: String(r.id),
type: r.type || (r.is_series ? 'series' : 'movie'),
source: 'hydracker',
hydrackerId: String(r.id)
}));
}
export function parseTrendingResults(data: any): SearchResult[] {
if (!data) return [];
const results = (data.pagination || {}).data || data.data || [];
return results.map((r: any) => ({
title: r.name,
year: r.year || (r.release_date ? r.release_date.substring(0, 4) : 'N/A'),
image: r.poster || r.image || null,
hrefPath: String(r.id),
type: r.type || (r.is_series ? 'series' : 'movie'),
source: 'hydracker',
hydrackerId: String(r.id)
})).slice(0, 19);
}
export function parseMovieLinks(data: any): VideoLink[] {
const all: any[] = [];
if (data.video) all.push(data.video);
if (Array.isArray(data.alternative_videos)) all.push(...data.alternative_videos);
return all.filter(l => l.lien).map(l => ({
id: l.id,
host: (l.host && l.host.name) ? l.host.name : 'Inconnu',
url: l.lien || data.directDL,
size: formatSize(l.taille),
sizeBytes: l.taille || 0,
quality: l.quality || QUALITY_MAP[l.qualite] || 'Inconnu',
langs: getLangs(l),
subs: getSubs(l),
releaseName: l.release || l.name || l.titre || l.titre_release || undefined,
}));
}
export function parseSeasons(result: any): number[] {
if (result && !result.error) {
const seasons = result.seasons || (result.pagination || {}).data || [];
if (Array.isArray(seasons) && seasons.length) {
return seasons
.map((s: any) => typeof s === 'object' ? (s.number || s) : s)
.filter((n: any) => typeof n === 'number' && n > 0)
.sort((a: number, b: number) => a - b);
}
}
return [];
}
export function parsePremiumLink(body: string): string | null {
let data;
try { data = JSON.parse(body); } catch { return null; }
let lienData = null;
if (data.liens && Array.isArray(data.liens) && data.liens.length > 0) {
lienData = data.liens[0];
} else {
lienData = data.lien || data;
}
return lienData.lien || lienData.url || lienData.link || null;
}
+165
View File
@@ -0,0 +1,165 @@
import { ISource, SearchResult, SelectionData, ContentLinks, MediaType } from '../../src/types/source.js';
import { CONFIG } from '../../src/utils/config.js';
export class LoadixSource implements ISource {
name = 'loadix';
displayName = 'Loadix';
private get frontUrl() { return (CONFIG.LOADIX_URL || '').replace(/\/+$/, ''); }
private get baseUrl() {
const urlObj = new URL(this.frontUrl);
return `https://api.${urlObj.host}/api`;
}
private tmdbImageBase = 'https://image.tmdb.org/t/p/w500';
private mapType(type: string): MediaType {
if (type === 'series') return 'series';
if (type === 'anime') return 'anime';
return 'movie';
}
private formatSearchResult(hit: any): SearchResult {
return {
title: hit.title,
year: hit.year ? hit.year.toString() : null,
image: hit.posterPath ? `${this.tmdbImageBase}${hit.posterPath}` : null,
hrefPath: `${this.frontUrl}/media/${hit.id}`,
type: this.mapType(hit.type),
source: this.name
};
}
async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
const url = `${this.baseUrl}/media/search?q=${encodeURIComponent(query)}&page=1&pageSize=30`;
const res = await fetch(url);
const data = await res.json();
let hits = data.hits || [];
if (mediaType && mediaType !== 'other') {
hits = hits.filter((h: any) => this.mapType(h.type) === mediaType);
}
return hits.map((h: any) => this.formatSearchResult(h));
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
const url = `${this.baseUrl}/media/search?q=&page=1&pageSize=30&sort=click_count_desc`;
const res = await fetch(url);
const data = await res.json();
let hits = data.hits || [];
if (mediaType && mediaType !== 'other') {
hits = hits.filter((h: any) => this.mapType(h.type) === mediaType);
}
return hits.map((h: any) => this.formatSearchResult(h));
}
async getRecent(): Promise<SearchResult[]> {
const url = `${this.baseUrl}/media/recent?limit=24`;
const res = await fetch(url);
const data = await res.json();
const items = data.items || [];
return items.map((h: any) => this.formatSearchResult(h));
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
const idMatch = identifier.match(/media\/([a-f0-9\-]+)/);
if (!idMatch) throw new Error("URL Loadix invalide.");
const mediaId = idMatch[1];
// Fetch links
const url = `${this.baseUrl}/media/${mediaId}/links?page=1&perPage=100&sort=scope_asc`;
const res = await fetch(url);
const data = await res.json();
const items = data.items || [];
const links = items.map((item: any) => {
let episode = null;
if (item.scope === 'season' && item.seasonNumber) {
episode = `S${String(item.seasonNumber).padStart(2, '0')}`;
} else if (item.scope === 'episode' && item.seasonNumber && item.episodeNumber) {
episode = `S${String(item.seasonNumber).padStart(2, '0')}E${String(item.episodeNumber).padStart(2, '0')}`;
}
return {
id: `${identifier}|${item.id}`,
host: item.provider || 'unknown',
quality: item.quality,
langs: item.language ? [item.language] : [],
sizeBytes: item.sizeBytes ? parseInt(item.sizeBytes) : undefined,
size: item.sizeHuman,
releaseName: item.releaseGroup,
episode: episode,
url: null // Protected by Turnstile, resolved later by direct redirect
};
});
// Check if there are any episodes/seasons to determine if it's a series
const isSeries = links.some((l: any) => l.episode);
// Extract seasons (just based on found links)
const seasonsMap = new Map<string, string>();
if (isSeries) {
items.forEach((item: any) => {
if (item.seasonNumber) {
const seasonStr = `Saison ${item.seasonNumber}`;
seasonsMap.set(String(item.seasonNumber), seasonStr);
}
});
}
const seasons = Array.from(seasonsMap.entries()).map(([val, label]) => ({
value: val,
label: label
}));
return {
links,
seasons,
isSeries
};
}
async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
const selection = await this.getSelection(identifier);
let links = selection.links;
if (season) {
const seasonPrefix = `S${String(season).padStart(2, '0')}`;
links = links.filter(l => l.episode && l.episode.startsWith(seasonPrefix));
}
return { links };
}
async healthCheck(): Promise<boolean> {
if (!this.frontUrl) {
console.warn('[Loadix] ⚠️ LOADIX_URL non définie.');
return false;
}
try {
const results = await this.getRecent();
return results.length > 0;
} catch (e: any) {
console.error(`[Loadix] Healthcheck failed: ${e.message}`);
return false;
}
}
async resolveLink(linkId: string, extraData?: any): Promise<any> {
const [url] = linkId.split('|');
// Like Flixart, Turnstile cannot be solved on localhost.
// We directly return the manual redirection challenge to open Loadix.
return {
captcha: 'turnstile',
url: url,
sourceName: 'Loadix'
};
}
}
// Auto-registration
import { sourceRegistry } from '../../src/core/registry.js';
sourceRegistry.register(new LoadixSource());
+563
View File
@@ -0,0 +1,563 @@
import { ISource, SearchResult, MediaType, ContentLinks, SelectionData, VideoLink } from '../../src/types/source.js';
import { CONFIG } from '../../src/utils/config.js';
import { sourceRegistry } from '../../src/core/registry.js';
import fs from 'fs';
import path from 'path';
import { DatabaseSync } from 'node:sqlite';
type IndexedTitle = {
norm: string;
normOrig: string;
// Distinct token list for the entry (union of norm + normOrig words).
// Precomputed at index build time so the search hot path never
// re-splits/dedupes these strings.
words: string[];
title_name: string;
original_title: string | null;
tmdb_id: number;
category_name: string;
title_poster: string | null;
created_at: string | null;
};
export class LocalDatabaseAPI implements ISource {
name = 'localdb';
displayName = 'Base de données locale';
private db: any = null;
private dbPath: string;
private titleIndex: IndexedTitle[] | null = null;
// Inverted indexes used by search() to shrink the candidate set from
// ~104K rows down to <2K before running tier scoring. Populated by
// buildTitleIndex(); never read or written outside of that method
// and search().
private tokenIndex: Map<string, number[]> | null = null; // exact token -> row indices
private titleByNorm: Map<string, number[]> | null = null; // full norm -> row indices (Tier 1)
private prefixIndex: Map<string, number[]> | null = null; // 2-char prefix-> row indices (Tier 2 + fuzzy)
constructor() {
this.dbPath = path.resolve(CONFIG.DB_PATH || './database/darkiworld.db');
}
private initDb(): boolean {
if (this.db) return true;
if (!fs.existsSync(this.dbPath)) {
return false;
}
try {
// readOnly avoids journal/WAL writes (plugin only reads).
this.db = new DatabaseSync(this.dbPath, { readOnly: true });
// Keep SQLite's temp store in RAM so big GROUP BY / sort
// operations don't spill to /tmp (a small tmpfs in the
// hardened container). Also bump page cache + mmap for
// the initial index scan.
for (const p of [
'PRAGMA temp_store = MEMORY',
'PRAGMA cache_size = -8000', // ~8MB page cache
'PRAGMA mmap_size = 67108864', // 64MB mmap, not 256MB
]) {
this.db.prepare(p).run();
}
return true;
} catch (e: any) {
console.error('[LocalDB] ❌ Erreur lors de l\'ouverture de la base SQLite native:', e.message);
return false;
}
}
async healthCheck(): Promise<boolean> {
const ok = this.initDb();
if (ok) {
// Warm the search indexes right after registration so the
// first /search request doesn't eat the multi-second build
// cost. setImmediate yields the current tick — the parallel
// health checks of other plugins still run first.
setImmediate(() => {
try { this.buildTitleIndex(); }
catch (e: any) { console.error('[LocalDB] Index warmup failed:', e.message); }
});
}
return ok;
}
// Lowercase, strip diacritics, strip apostrophes, collapse to alnum tokens.
// "Pokémon: l'aventure" -> "pokemon l aventure"
private static normalize(s: string | null | undefined): string {
if (!s) return '';
return s
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/['"`’ʼ]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
// Bounded Levenshtein. Returns max+1 if it would exceed `max` (cheap exit).
private static editDistance(a: string, b: string, max: number): number {
const la = a.length, lb = b.length;
if (Math.abs(la - lb) > max) return max + 1;
if (la === 0) return lb;
if (lb === 0) return la;
let prev = new Array(lb + 1);
let curr = new Array(lb + 1);
for (let j = 0; j <= lb; j++) prev[j] = j;
for (let i = 1; i <= la; i++) {
curr[0] = i;
let rowMin = curr[0];
const ai = a.charCodeAt(i - 1);
for (let j = 1; j <= lb; j++) {
const cost = ai === b.charCodeAt(j - 1) ? 0 : 1;
const v = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
curr[j] = v;
if (v < rowMin) rowMin = v;
}
if (rowMin > max) return max + 1;
const tmp = prev; prev = curr; curr = tmp;
}
return prev[lb];
}
// Per-token edit-distance budget. Short words must match almost exactly;
// longer words tolerate more typos.
private static fuzzyBudget(tok: string): number {
if (tok.length <= 3) return 0;
if (tok.length <= 5) return 1;
if (tok.length <= 8) return 2;
return 3;
}
private buildTitleIndex(): void {
if (this.titleIndex !== null) return;
if (!this.initDb()) {
this.titleIndex = [];
this.tokenIndex = new Map();
this.titleByNorm = new Map();
this.prefixIndex = new Map();
return;
}
const t0 = Date.now();
const sql = `
SELECT title_name,
original_title,
tmdb_id,
category_name,
title_poster,
MIN(created_at) AS created_at
FROM links_small
GROUP BY title_name, tmdb_id
`;
const rows = this.db.prepare(sql).all() as any[];
const titleIndex = new Array<IndexedTitle>(rows.length);
const tokenIndex = new Map<string, number[]>();
const titleByNorm = new Map<string, number[]>();
const prefixIndex = new Map<string, number[]>();
const push = (m: Map<string, number[]>, key: string, idx: number) => {
const list = m.get(key);
if (list) list.push(idx);
else m.set(key, [idx]);
};
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
const norm = LocalDatabaseAPI.normalize(r.title_name);
const normOrig = LocalDatabaseAPI.normalize(r.original_title);
// Deduplicated union of words from both title fields.
const seen = new Set<string>();
const words: string[] = [];
if (norm) for (const w of norm.split(' ')) if (w && !seen.has(w)) { seen.add(w); words.push(w); }
if (normOrig) for (const w of normOrig.split(' ')) if (w && !seen.has(w)) { seen.add(w); words.push(w); }
titleIndex[i] = {
norm, normOrig, words,
title_name: r.title_name,
original_title: r.original_title,
tmdb_id: r.tmdb_id || 0,
category_name: r.category_name,
title_poster: r.title_poster,
created_at: r.created_at,
};
if (norm) push(titleByNorm, norm, i);
if (normOrig && normOrig !== norm) push(titleByNorm, normOrig, i);
for (const w of words) {
push(tokenIndex, w, i);
if (w.length >= 2) push(prefixIndex, w.slice(0, 2), i);
}
}
this.titleIndex = titleIndex;
this.tokenIndex = tokenIndex;
this.titleByNorm = titleByNorm;
this.prefixIndex = prefixIndex;
console.log(`[LocalDB] Index construit: ${titleIndex.length} titres en ${Date.now() - t0}ms ` +
`(tokens=${tokenIndex.size}, prefixes=${prefixIndex.size})`);
}
private mapCategoryToType(category: string): MediaType {
const cat = (category || '').toLowerCase().trim();
// Livres & BD
if (cat.match(/\b(bd|livres?|ebooks?|magazines?|journaux)\b/)) return 'book';
// Jeux
if (cat.match(/\b(jeux?|consoles?)\b/)) return 'game';
// Logiciels & Formations
if (cat.match(/\b(logiciels?|formations?)\b/)) return 'software';
// Musique
if (cat.match(/\b(musiques?|audio)\b/)) return 'music';
// Séries
if (cat.includes('série') || cat.includes('serie') || cat.includes('tv') || cat.includes('emission')) return 'series';
// Animes / Dessins animés
if (cat.includes('anime') || cat.includes('manga') || cat.includes('dessin')) return 'anime';
// Films (Films HD, Documentaires, Spectacles...)
if (cat.includes('film') || cat.includes('spectacle') || cat.includes('documentaire') || cat === '') return 'movie';
// Tout le reste
return 'other';
}
async search(query: string, mediaType: any = 'movie'): Promise<SearchResult[]> {
if (!this.initDb()) {
console.warn('[LocalDB] ⚠️ Base de données non initialisée ou introuvable.');
return [];
}
this.buildTitleIndex();
if (!this.titleIndex || this.titleIndex.length === 0) return [];
const t0 = Date.now();
const q = LocalDatabaseAPI.normalize(query);
if (!q) return [];
const tokens = q.split(' ').filter(Boolean);
if (tokens.length === 0) return [];
// Candidate row indices, gathered from the inverted indexes. For
// a typical query this drops the working set from ~104K rows to
// a few hundred. Rows that don't show up here cannot match Tier
// 1, 2, 3 or 5 — the only thing they could theoretically hit is
// Tier 4 substring-inside-a-word, which is rare enough not to
// justify a trigram index.
const candidates = new Set<number>();
const exactHits = this.titleByNorm!.get(q);
if (exactHits) for (const i of exactHits) candidates.add(i);
for (const tok of tokens) {
const rows = this.tokenIndex!.get(tok);
if (rows) for (const i of rows) candidates.add(i);
if (tok.length >= 2) {
const pRows = this.prefixIndex!.get(tok.slice(0, 2));
if (pRows) for (const i of pRows) candidates.add(i);
}
}
const scored: Array<{ idx: number; score: number }> = [];
for (const i of candidates) {
const entry = this.titleIndex[i];
const t = entry.norm;
const o = entry.normOrig;
let score = 0;
// Tier 1: exact normalized match on either title field
if (t === q || (o && o === q)) {
score = 1000;
}
// Tier 2: title starts with the full query
else if (t.startsWith(q) || (o && o.startsWith(q))) {
score = 800;
}
// Tier 3: query appears as a whole-word substring
else if ((' ' + t + ' ').includes(' ' + q + ' ') ||
(o && (' ' + o + ' ').includes(' ' + q + ' '))) {
score = 700;
}
// Tier 4: raw substring (partial word)
else if (t.includes(q) || (o && o.includes(q))) {
score = 600;
}
// Tier 5: per-token matching, exact-then-fuzzy, any word order.
// Uses the precomputed entry.words instead of re-splitting on
// every row.
else {
const words = entry.words;
let exactMatched = 0;
let fuzzyMatched = 0;
let fuzzyPenalty = 0;
let anyMatched = false;
for (const tok of tokens) {
let exact = false;
for (const w of words) {
if (w === tok || w.startsWith(tok)) { exact = true; break; }
}
if (exact) {
exactMatched++;
anyMatched = true;
continue;
}
const budget = LocalDatabaseAPI.fuzzyBudget(tok);
if (budget === 0) continue;
let best = budget + 1;
for (const w of words) {
if (Math.abs(w.length - tok.length) > budget) continue;
const d = LocalDatabaseAPI.editDistance(tok, w, budget);
if (d < best) { best = d; if (best <= 1) break; }
}
if (best <= budget) {
fuzzyMatched++;
fuzzyPenalty += best;
anyMatched = true;
}
}
const totalMatched = exactMatched + fuzzyMatched;
if (totalMatched === tokens.length) {
// All tokens covered — strong signal even when some were fuzzy
score = 400 - fuzzyPenalty * 30 + exactMatched * 5;
} else if (anyMatched) {
// Partial coverage — only meaningful for multi-word queries
score = Math.round(120 * (totalMatched / tokens.length)) - fuzzyPenalty * 10;
}
}
if (score > 0) {
// Tiebreakers: shorter titles win; original_title field is a small bonus when it helped
score += Math.max(0, 30 - t.length);
scored.push({ idx: i, score });
}
}
scored.sort((a, b) => b.score - a.score);
const results: SearchResult[] = scored.slice(0, 150).map(({ idx }) => {
const r = this.titleIndex![idx];
const type = this.mapCategoryToType(r.category_name);
return {
title: r.title_name,
year: r.created_at ? r.created_at.substring(0, 4) : null,
image: r.title_poster || null,
hrefPath: `localdb:${r.tmdb_id}:${r.title_name}`,
type,
source: this.name
};
});
const filtered = (mediaType === 'movie')
? results.filter(r => r.type === 'movie' || r.type === 'anime')
: (mediaType === 'series')
? results.filter(r => r.type === 'series' || r.type === 'anime')
: (mediaType === 'movie_series')
? results.filter(r => r.type === 'movie' || r.type === 'series' || r.type === 'anime')
: results.filter(r => r.type === mediaType);
console.log(`[LocalDB] search "${query}" → ${candidates.size} candidats, ${filtered.length} résultats en ${Date.now() - t0}ms`);
return filtered;
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
// Pas de tendances en base de données locale
return [];
}
// Distinct quality/host values from the DB, grouped by media bucket.
// Cached after the first call — the underlying data is static.
private optionsCache: { qualities: { movies: string[]; series: string[] }; hosts: string[] } | null = null;
listConfigOptions(): { qualities: { movies: string[]; series: string[] }; hosts: string[] } {
if (this.optionsCache) return this.optionsCache;
const empty = { qualities: { movies: [] as string[], series: [] as string[] }, hosts: [] as string[] };
if (!this.initDb()) return empty;
try {
const movieCats = ['Films', 'Animes', 'Films et series', 'Documentaire', 'Spectacle'];
const seriesCats = ['Séries', 'Animes', 'Téléréalité', 'Émissions TV', 'Mangas'];
const sql = (cats: string[]) => `
SELECT DISTINCT quality_name FROM links_small
WHERE category_name IN (${cats.map(() => '?').join(',')})
AND quality_name IS NOT NULL AND quality_name != ''
ORDER BY quality_name`;
const pick = (cats: string[]): string[] =>
this.db.prepare(sql(cats)).all(...cats).map((r: any) => r.quality_name);
const hosts = this.db.prepare(
`SELECT DISTINCT host_name FROM links_small
WHERE host_name IS NOT NULL AND host_name != ''
ORDER BY host_name`
).all().map((r: any) => r.host_name);
this.optionsCache = {
qualities: { movies: pick(movieCats), series: pick(seriesCats) },
hosts,
};
return this.optionsCache;
} catch (e: any) {
console.error('[LocalDB] listConfigOptions error:', e.message);
return empty;
}
}
private parseIdentifier(identifier: string): { tmdbId: number; titleName: string } {
const parts = identifier.split(':');
if (parts[0] === 'localdb') {
return {
tmdbId: parseInt(parts[1], 10) || 0,
titleName: parts.slice(2).join(':')
};
}
return { tmdbId: 0, titleName: identifier };
}
async getContentLinks(identifier: string, season: number = 1): Promise<ContentLinks> {
if (!this.initDb()) return { links: [] };
const { tmdbId, titleName } = this.parseIdentifier(identifier);
try {
let categoryStmt = this.db.prepare('SELECT category_name FROM links_small WHERE tmdb_id = ? OR title_name = ? LIMIT 1');
let sample = categoryStmt.get(tmdbId, titleName) as any;
if (!sample && tmdbId > 0) {
sample = categoryStmt.get(0, titleName) as any;
}
if (!sample) return { links: [] };
const mediaType = this.mapCategoryToType(sample.category_name);
if (!mediaType) return { links: [] };
const isSeries = mediaType === 'series';
let rows: any[] = [];
if (isSeries) {
const sql = `
SELECT * FROM links_small
WHERE (tmdb_id = ? OR title_name = ?) AND season_number = ?
ORDER BY episode_number ASC, quality_name DESC
`;
rows = this.db.prepare(sql).all(tmdbId, titleName, season) as any[];
} else {
const sql = `
SELECT * FROM links_small
WHERE tmdb_id = ? OR title_name = ?
ORDER BY quality_name DESC
`;
rows = this.db.prepare(sql).all(tmdbId, titleName) as any[];
}
const splitLangs = (s: string | null | undefined): string[] => {
if (!s) return [];
return s.split(/[,;/]+/).map(p => p.trim()).filter(Boolean);
};
const links: VideoLink[] = rows.map((row: any, i: number) => {
const idKey = row.link_id != null ? String(row.link_id) : `local_${i}`;
const audioLangs = splitLangs(row.audio_langs);
const subLangs = splitLangs(row.sub_langs);
// Legacy `langs` field — kept for plugins/clients that don't
// know about audioLangs/subLangs yet.
const langsList = [...audioLangs];
if (subLangs.length) langsList.push(`Subs: ${subLangs.join(', ')}`);
return {
id: idKey,
host: row.host_name || 'Inconnu',
url: row.link_url || null,
size: row.size_human || '0 Bytes',
sizeBytes: row.size_bytes || 0,
quality: row.quality_name || 'BDRip',
langs: langsList,
episode: row.is_full_season
? 'Saison complète'
: (row.episode_number ? `Épisode ${row.episode_number}` : null),
episodeNumber: row.episode_number || null,
episodeName: row.episode_name || null,
isFullSeason: !!row.is_full_season,
audioLangs,
subLangs,
};
});
return { links };
} catch (e: any) {
console.error('[LocalDB] Erreur getContentLinks:', e.message);
return { links: [] };
}
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
if (!this.initDb()) return { links: [], seasons: [], isSeries: false };
const { tmdbId, titleName } = this.parseIdentifier(identifier);
try {
const sample = this.db.prepare('SELECT category_name FROM links_small WHERE tmdb_id = ? OR title_name = ? LIMIT 1').get(tmdbId, titleName) as any;
if (!sample) return { links: [], seasons: [], isSeries: false };
const mediaType = this.mapCategoryToType(sample.category_name);
if (!mediaType) return { links: [], seasons: [], isSeries: false };
const isSeries = mediaType === 'series';
let seasonsList: any[] = [];
let currentSeason = 1;
if (isSeries) {
const seasonsRows = this.db.prepare(`
SELECT DISTINCT season_number
FROM links_small
WHERE tmdb_id = ? OR title_name = ?
ORDER BY season_number ASC
`).all(tmdbId, titleName) as any[];
seasonsList = seasonsRows.map((r: any) => ({
label: `Saison ${r.season_number}`,
value: r.season_number
}));
if (seasonValue) {
currentSeason = parseInt(String(seasonValue), 10) || 1;
} else if (seasonsRows.length > 0) {
// Prefer season 1 if it exists (matches the UI's auto-selected
// dropdown option); otherwise fall back to the lowest season
// number — usually "Saison 0" specials.
const hasSeason1 = seasonsRows.some((r: any) => r.season_number === 1);
currentSeason = hasSeason1 ? 1 : seasonsRows[0].season_number;
}
}
const content = await this.getContentLinks(identifier, currentSeason);
return {
links: content.links,
seasons: seasonsList,
isSeries
};
} catch (e: any) {
console.error('[LocalDB] Erreur getSelection:', e.message);
return { links: [], seasons: [], isSeries: false };
}
}
resolveLocalLink(linkId: string | number): string | null {
if (!this.initDb()) return null;
try {
const row = this.db.prepare('SELECT link_url FROM links_small WHERE link_id = ? LIMIT 1').get(linkId) as any;
if (row && row.link_url) {
return row.link_url;
}
} catch (e: any) {
console.error('[LocalDB] Erreur resolveLocalLink:', e.message);
}
return null;
}
}
// Enregistrement automatique du plugin
sourceRegistry.register(new LocalDatabaseAPI());
+47
View File
@@ -0,0 +1,47 @@
import { CONFIG } from '../../src/utils/config.js';
export class MovixAPI {
private static get baseUrl(): string {
return CONFIG.MOVIX_URL || '';
}
private static get apiUrl(): string {
if (!this.baseUrl) return '';
try {
const url = new URL(this.baseUrl);
return `${url.protocol}//api.${url.host}/api`;
} catch {
return '';
}
}
private static getHeaders() {
return {
'Accept': 'application/json, text/plain, */*',
'Origin': this.baseUrl,
'Referer': `${this.baseUrl}/`,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 OPR/133.0.0.0'
};
}
public static async search(query: string): Promise<any> {
const url = `${this.apiUrl}/search?title=${encodeURIComponent(query)}`;
const res = await fetch(url, { headers: this.getHeaders() });
if (!res.ok) throw new Error(`Movix Search HTTP ${res.status}`);
return res.json();
}
public static async getDownloadLinks(type: string, id: number | string, tmdbId: number | string): Promise<any> {
const url = `${this.apiUrl}/darkiworld/download/${type}/${id}?tmdbId=${tmdbId}`;
const res = await fetch(url, { headers: this.getHeaders() });
if (!res.ok) throw new Error(`Movix Download HTTP ${res.status}`);
return res.json();
}
public static async decodeLink(linkId: string | number, titleId: string | number): Promise<any> {
const url = `${this.apiUrl}/darkiworld/decode/${linkId}?title_id=${titleId}`;
const res = await fetch(url, { headers: this.getHeaders() });
if (!res.ok) throw new Error(`Movix Decode HTTP ${res.status}`);
return res.json();
}
}
+207
View File
@@ -0,0 +1,207 @@
import { ISource, SearchResult, SelectionData, ContentLinks, MediaType, VideoLink } from '../../src/types/source.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { MovixAPI } from './api.js';
import { CONFIG } from '../../src/utils/config.js';
class MovixSource implements ISource {
public readonly name = 'movix';
public readonly displayName = 'Movix';
public async healthCheck(): Promise<boolean> {
if (!CONFIG.MOVIX_URL) return false;
try {
// A quick check to see if the search endpoint is reachable
await MovixAPI.search('test');
return true;
} catch (e) {
console.error(`[MOVIX] Health check failed:`, e);
return false;
}
}
public async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
try {
const response = await MovixAPI.search(query);
if (!response || !response.results) return [];
const results: SearchResult[] = [];
const searchLower = query.toLowerCase().trim();
for (const item of response.results) {
if (!item.name) continue;
// Filtre optionnel pour aligner les résultats avec la recherche
const nameLower = item.name.toLowerCase();
const originalLower = item.original_title ? item.original_title.toLowerCase() : '';
// On vérifie si la requête est incluse dans le titre ou le titre original
if (!nameLower.includes(searchLower) && !originalLower.includes(searchLower)) {
// Pour être un peu plus permissif, on vérifie si tous les mots clés y sont
const words = searchLower.split(' ');
const allWordsMatch = words.every(w => nameLower.includes(w) || originalLower.includes(w));
if (!allWordsMatch) continue;
}
// Filtrage basique par mediaType si fourni
if (mediaType) {
if (mediaType === 'movie' && item.type !== 'movie') continue;
if (mediaType === 'series' && item.type !== 'serie') continue; // Verify if it's 'serie' or 'series'
}
const hrefPath = `movix:${item.id}:${item.tmdb_id || 0}:${item.type}`;
let image = null;
if (item.poster) {
image = item.poster.startsWith('http') ? item.poster : `https://image.tmdb.org/t/p/w300/${item.poster}`;
}
results.push({
title: item.name,
year: item.year ? item.year.toString() : null,
image,
hrefPath,
type: item.type === 'movie' ? 'movie' : (item.type === 'serie' || item.type === 'series' ? 'series' : 'other'),
source: this.name
});
}
return results;
} catch (e) {
console.error(`[MOVIX] Search error:`, e);
return [];
}
}
public async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
const typeStr = mediaType === 'series' ? 'tv' : 'movie';
const url = `https://api.themoviedb.org/3/trending/${typeStr}/day?api_key=f3d757824f08ea2cff45eb8f47ca3a1e&language=fr-FR`;
try {
const res = await fetch(url);
const data = await res.json();
if (!data || !data.results) return [];
return data.results.map((item: any) => {
const title = item.title || item.name;
const year = item.release_date ? item.release_date.split('-')[0] : (item.first_air_date ? item.first_air_date.split('-')[0] : null);
const image = item.poster_path ? `https://image.tmdb.org/t/p/w300${item.poster_path}` : null;
const tmdbId = item.id;
const type = mediaType === 'series' ? 'series' : 'movie';
// Identifiant spécial pour faire la recherche au moment du clic
const hrefPath = `movix:tmdb:${tmdbId}:${type}:${encodeURIComponent(title)}`;
return {
title,
year,
image,
hrefPath,
type,
source: this.name
};
});
} catch(e) {
console.error(`[MOVIX] TMDB Trending error:`, e);
return [];
}
}
public async getRecent(): Promise<SearchResult[]> {
// Fallback on movies trending as recent if no specific endpoint
return this.getTrending('movie');
}
public async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
try {
const parts = identifier.split(':');
if (parts.length < 4) return { links: [] };
let id: string, tmdbId: string, type: string;
if (parts[1] === 'tmdb') {
tmdbId = parts[2];
type = parts[3];
const title = decodeURIComponent(parts.slice(4).join(':'));
// Recherche sur Movix pour récupérer l'ID interne
const searchRes = await MovixAPI.search(title);
const item = searchRes.results?.find((r: any) => String(r.tmdb_id) === String(tmdbId) || r.name === title);
if (!item) {
console.log(`[MOVIX] TMDB item not found on Movix: ${title}`);
return { links: [] };
}
id = item.id;
// Update type depending on what Movix returned
type = item.type === 'serie' || item.type === 'series' ? 'series' : 'movie';
} else {
id = parts[1];
tmdbId = parts[2];
type = parts[3];
}
const data = await MovixAPI.getDownloadLinks(type, id, tmdbId);
const links: VideoLink[] = [];
if (data && data.data) {
// Pour chaque host (1fichier, etc)
for (const item of data.data) {
if (!item.links || !Array.isArray(item.links)) continue;
const host = item.host || 'unknown';
const quality = item.qualite || 'Unknown';
const lang = item.langue || 'Unknown';
const size = item.size || '';
for (const linkObj of item.links) {
const linkId = linkObj.id;
if (!linkId) continue;
links.push({
id: `${linkId}|${id}`, // Store both linkId and titleId
host: host,
label: `${quality} - ${lang}`,
url: null, // Resolves later
size: size,
quality: quality,
langs: [lang]
});
}
}
}
return { links };
} catch (e) {
console.error(`[MOVIX] Error in getContentLinks:`, e);
return { links: [] };
}
}
public async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
const content = await this.getContentLinks(identifier);
return {
links: content.links,
seasons: [],
isSeries: type === 'series'
};
}
public async resolveLink(combinedId: string): Promise<string | null> {
try {
const [linkId, titleId] = combinedId.split('|');
if (!linkId || !titleId) return null;
const res = await MovixAPI.decodeLink(linkId, titleId);
if (res && res.url) {
return res.url;
}
return null;
} catch (e) {
console.error(`[MOVIX] ResolveLink error for ${combinedId}:`, e);
return null;
}
}
}
sourceRegistry.register(new MovixSource());
+37
View File
@@ -0,0 +1,37 @@
/**
* Appels réseau pour zone-telechargement.news.
* Pas de challenge CF actif, fetch direct simple.
*/
const TIMEOUT = 20_000;
const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0';
async function ztnGet(url: string): Promise<string> {
const res = await fetch(url, {
headers: {
'User-Agent': UA,
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
},
redirect: 'follow',
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchSearch(baseUrl: string, query: string): Promise<string> {
return ztnGet(`${baseUrl}/?p=films&search=${encodeURIComponent(query)}`);
}
/**
* News n'a pas vraiment de page "nouveautés" séparée la home expose déjà
* une grille de blocs cover_global avec les derniers films/séries.
*/
export async function fetchTrending(baseUrl: string, type: 'films' | 'series'): Promise<string> {
return ztnGet(`${baseUrl}/?p=${type}`);
}
export async function fetchPage(pageUrl: string): Promise<string> {
return ztnGet(pageUrl);
}
+133
View File
@@ -0,0 +1,133 @@
import { ISource, SearchResult, MediaType, SelectionData, ContentLinks } from '../../src/types/source.js';
import { CONFIG } from '../../src/utils/config.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { fetchSearch, fetchTrending, fetchPage } from './api.js';
import { parseListingHTML, parseContentHTML, parseOtherVersions } from './parser.js';
function isSeriesIdentifier(identifier: string): boolean {
return /[?&]p=serie\b|telecharger-serie/i.test(identifier);
}
function normalizeTitle(title: string): string {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/-\s*saison\s*\d+/gi, '')
.replace(/\(\s*\d{4}\s*\)/g, '')
.replace(/[^a-z0-9]/g, '');
}
function deduplicateByTitle(results: SearchResult[]): SearchResult[] {
const seen = new Set<string>();
return results.filter(r => {
const key = normalizeTitle(r.title);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export class ZtTeamAPI implements ISource {
name = 'ztnews';
displayName = 'Zone-Téléchargement (Team)';
get baseUrl() {
return CONFIG.ZTTEAM_URL?.replace(/\/$/, '');
}
async healthCheck(): Promise<boolean> {
if (!this.baseUrl) {
console.warn('[ztnews] ⚠️ ZTTEAM_URL non définie.');
return false;
}
return true;
}
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
if (!this.baseUrl) throw new Error('ZTTEAM_URL non configurée.');
if (!query || query.length < 3) throw new Error('La recherche nécessite au moins 3 caractères.');
const html = await fetchSearch(this.baseUrl, query);
let results = parseListingHTML(html, this.baseUrl);
if (mediaType === 'movie') {
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
} else {
results = results.filter(r => r.type === 'series' || r.type === 'anime');
}
return deduplicateByTitle(results);
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = await fetchTrending(this.baseUrl, mediaType === 'series' ? 'series' : 'films');
const results = parseListingHTML(html, this.baseUrl);
return deduplicateByTitle(results).slice(0, 20);
} catch (e: any) {
console.error(`[ztnews] Erreur trending ${mediaType}:`, e.message);
return [];
}
}
async getRecent(): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = await fetchPage(this.baseUrl);
const results = parseListingHTML(html, this.baseUrl);
return deduplicateByTitle(results).slice(0, 20);
} catch (e: any) {
console.error(`[ztnews] Erreur getRecent:`, e.message);
return [];
}
}
async getContentLinks(identifier: string): Promise<ContentLinks> {
if (!this.baseUrl) throw new Error('ZTTEAM_URL non configurée.');
const url = identifier.startsWith('http') ? identifier : `${this.baseUrl}/${identifier.replace(/^\//, '')}`;
const html = await fetchPage(url);
return parseContentHTML(html, isSeriesIdentifier(identifier));
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
if (!this.baseUrl) throw new Error('ZTTEAM_URL non configurée.');
const targetIdentifier = seasonValue ? String(seasonValue) : identifier;
const isSeries = isSeriesIdentifier(targetIdentifier);
const url = targetIdentifier.startsWith('http') ? targetIdentifier : `${this.baseUrl}/${targetIdentifier.replace(/^\//, '')}`;
const html = await fetchPage(url);
const content = parseContentHTML(html, isSeries);
const allLinks = [...content.links];
let seasons: { label: string; value: string }[] = [];
if (!isSeries) {
const otherVersions = parseOtherVersions(html, this.baseUrl);
if (otherVersions.length > 0) {
console.log(`[ztnews] Fetching ${otherVersions.length} other qualities concurrently...`);
const qualityPromises = otherVersions.map(async (q) => {
try {
const qHtml = await fetchPage(q.value);
const qContent = parseContentHTML(qHtml, isSeries);
return qContent.links;
} catch (e) {
console.error(`[ztnews] Error fetching quality page ${q.value}:`, e);
return [];
}
});
const otherQualitiesLinks = await Promise.all(qualityPromises);
otherQualitiesLinks.forEach(links => allLinks.push(...links));
}
}
return {
links: allLinks,
seasons,
isSeries,
};
}
async resolveLink(linkId: string): Promise<string | null> {
console.log(`[ztTeam] 🔗 Renvoi du lien dl-protect brut (résolution via navigateur ou JDownloader requise) : ${linkId}`);
return linkId || null;
}
}
sourceRegistry.register(new ZtTeamAPI());
+175
View File
@@ -0,0 +1,175 @@
import { SearchResult, ContentLinks, VideoLink } from '../../src/types/source.js';
function decodeFnMeta(url: string): { quality?: string; langs?: string[] } {
try {
const m = url.match(/[?&]fn=([^&]+)/);
if (!m) return {};
const decoded = Buffer.from(decodeURIComponent(m[1]!), 'base64').toString('utf-8');
const qm = decoded.match(/\[([^\]]+)\]/);
const quality = qm ? qm[1]!.trim() : undefined;
// Tout après " - " jusqu'à la fin (typiquement la langue : FRENCH, MULTI, VOSTFR…)
const lm = decoded.match(/-\s+([A-Za-z]+(?:\s+[A-Za-z]+)?)$/);
const langs = lm ? [lm[1]!.trim()] : undefined;
return { quality, langs };
} catch {
return {};
}
}
function detectType(href: string): 'movie' | 'series' | 'anime' {
if (/[?&]p=serie\b|telecharger-serie|serie-/i.test(href)) return 'series';
if (/animes?/i.test(href)) return 'anime';
return 'movie';
}
function absUrl(url: string, baseUrl: string): string {
if (url.startsWith('http')) return url;
const cleanedBase = baseUrl.replace(/\/$/, '');
return cleanedBase + (url.startsWith('/') ? url : '/' + url);
}
/**
* Strip suffixes de qualité/langue pour dedup par titre normalisé.
*/
function normalizeTitle(title: string): string {
return title
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/\b(web-?dl|web-?rip|blu-?ray|hdtv|hdrip|dvdrip|hdlight|truefrench|french|multi(?:langues?)?|vff|vf|vostfr|x264|x265|hevc)\b/g, '')
.replace(/\b(720p|1080p|2160p|4k|uhd|3d|sd|hd)\b/g, '')
.replace(/\(\s*\d{4}\s*\)/g, '')
.replace(/-\s*saison\s*\d+/gi, '')
.replace(/[^a-z0-9]/g, '');
}
function deduplicateByTitle<T extends { title: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter(it => {
const k = normalizeTitle(it.title);
if (!k || seen.has(k)) return false;
seen.add(k);
return true;
});
}
/**
* News utilise la structure DLE classique avec cover_global / cover_infos_title / mainimg
* sur la home/listing/recherche. On peut donc partager le parser de listing.
*/
export function parseListingHTML(html: string, baseUrl: string): SearchResult[] {
const results: SearchResult[] = [];
const coverRegex = /<div class="cover_global"[^>]*>([\s\S]*?)(?=<div class="cover_global"|$)/g;
let m: RegExpExecArray | null;
while ((m = coverRegex.exec(html)) !== null) {
const block = m[1]!;
const titleMatch = block.match(/<div class="cover_infos_title"[^>]*>\s*<a href="([^"]+)"[^>]*>\s*([^<]+)/);
if (!titleMatch) continue;
const href = absUrl(titleMatch[1]!.trim(), baseUrl);
const title = titleMatch[2]!.trim();
const imgMatch = block.match(/<img class="mainimg"[^>]*src="([^"]+)"/);
const image = imgMatch ? absUrl(imgMatch[1]!, baseUrl) : null;
let year: string | null = null;
const yearMatch = title.match(/\(\s*(\d{4})\s*\)/) || href.match(/-(\d{4})-/);
if (yearMatch) {
year = yearMatch[1];
}
results.push({
title,
year,
image,
hrefPath: href,
type: detectType(titleMatch[1]!),
source: 'ztnews',
});
}
return deduplicateByTitle(results);
}
/**
* Parse la fiche film/série de news.
* Structure dans <div class="postinfo">:
* <div style="color:#XXX">HOST_NAME</div>
* <a href="dl-protect.link/SLUG?fn=...&rl=a2">Télécharger</a> (film)
* <a href="dl-protect.link/SLUG?fn=...&rl=b2">Episode N</a> (série, plusieurs liens)
*/
export function parseContentHTML(html: string, isSeries: boolean): ContentLinks {
const links: VideoLink[] = [];
const postMatch = html.match(/<div class="postinfo">([\s\S]*?)<\/div>\s*<\/center>/);
if (!postMatch) return { links };
const post = postMatch[1]!;
// Découper par hôte : chaque hôte est marqué par <div style="font-weight:bold;color:#XXX">HOST</div>
const hostSplit = post.split(/<div\s+style="font-weight:bold;color:#[0-9a-fA-F]+">([^<]+)<\/div>/);
// hostSplit[0] = pre-section, puis alterne (HOST, BLOCK)
for (let i = 1; i < hostSplit.length; i += 2) {
const host = hostSplit[i]!.trim();
const block = hostSplit[i + 1] || '';
// Tous les <a href="dl-protect.link..."> dans cette section
const linkRegex = /<a[^>]+href="(https?:\/\/dl-protect\.link\/[0-9a-fA-F]+\?[^"]*?rl=[ab]2[^"]*)"[^>]*>([^<]+)<\/a>/g;
let lm: RegExpExecArray | null;
while ((lm = linkRegex.exec(block)) !== null) {
const url = lm[1]!;
const label = lm[2]!.trim();
const epMatch = label.match(/Episode\s*(\d+|FiNAL|Final|final)/i);
const meta = decodeFnMeta(url);
let quality = meta.quality || 'Inconnu';
let langs: string[] = [];
let subs: string[] = [];
const textToScan = `${quality} ${label}`;
const langMatch = textToScan.match(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi);
if (langMatch) {
const seenLangs = new Set<string>();
const seenSubs = new Set<string>();
langMatch.forEach(l => {
const up = l.toUpperCase();
if (up.includes('VOSTFR')) { seenLangs.add('VOSTFR'); seenSubs.add('French'); }
else if (up.includes('TRUEFRENCH')) seenLangs.add('TrueFrench');
else if (up.includes('FRENCH') || up === 'VF' || up === 'VFF') seenLangs.add('French');
else if (up.includes('MULTI')) { seenLangs.add('MULTI'); seenSubs.add('Multi'); }
});
langs = Array.from(seenLangs);
subs = Array.from(seenSubs);
quality = quality.replace(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi, '').trim();
}
quality = quality.replace(/[\(\)\[\]\-]+$/g, '').replace(/[\(\)\[\]]/g, '').replace(/\s+/g, ' ').trim();
if (!quality || quality.toLowerCase() === 'inconnu') quality = 'WEB';
links.push({
id: url,
host: host.toLowerCase(),
label: isSeries ? `${label}${host}` : host,
episode: epMatch ? epMatch[1] : undefined,
quality: quality,
langs: langs,
subs: subs,
url: null,
});
}
}
return { links };
}
/**
* Extrait les autres versions/qualités du film depuis la section "Qualités également disponibles".
*/
export function parseOtherVersions(html: string, baseUrl: string): { label: string; value: string }[] {
const out: { label: string; value: string }[] = [];
const sectionMatch = html.match(/<div class="otherversions"[\s\S]*?<\/div>/);
if (!sectionMatch) return out;
const linkRegex = /<a\s+href="([^"]+)"[^>]*>\s*<span class="otherquality">([\s\S]*?)<\/span>\s*<\/a>/g;
let m: RegExpExecArray | null;
while ((m = linkRegex.exec(sectionMatch[0])) !== null) {
const href = absUrl(m[1]!, baseUrl);
const label = m[2]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
if (label && !out.find(o => o.value === href)) out.push({ label, value: href });
}
return out;
}
+81
View File
@@ -0,0 +1,81 @@
// Helper functions for password toggles and validation shared between login, setup, and overlay
window.AuthHelpers = {
/**
* Initializes password visibility toggles.
* @param {HTMLElement} rootEl
*/
initPasswordToggles(rootEl = document) {
rootEl.querySelectorAll('.toggle-password').forEach(btn => {
if (btn.dataset.initialized) return;
btn.dataset.initialized = 'true';
btn.addEventListener('click', () => {
const input = btn.parentElement.querySelector('input');
if (!input) return;
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
btn.innerHTML = isPassword
? '<i data-lucide="eye-off" class="eye-icon"></i>'
: '<i data-lucide="eye" class="eye-icon"></i>';
if (typeof lucide !== 'undefined') {
lucide.createIcons({ root: btn });
}
});
});
},
/**
* Performs client-side password validation against complexity rules.
* @param {string} val
* @param {string} confirmVal
* @returns {Object}
*/
validateComplexity(val, confirmVal) {
const hasLength = val.length >= 8;
const hasUpper = /[A-Z]/.test(val);
const hasNumber = /[0-9]/.test(val);
const hasSpecial = /[^a-zA-Z0-9]/.test(val);
const matches = val === confirmVal && val.length > 0;
return {
hasLength,
hasUpper,
hasNumber,
hasSpecial,
matches,
allValid: hasLength && hasUpper && hasNumber && hasSpecial && matches
};
},
/**
* Updates requirements list checklist UI.
* @param {HTMLElement} rootEl
* @param {Object} statuses
*/
updateRequirementsUI(rootEl, statuses) {
const updateReq = (id, isValid) => {
const li = rootEl.querySelector(`#${id}`);
if (!li) return;
li.className = isValid ? 'valid' : 'invalid';
const holder = li.querySelector('.icon-holder');
if (holder) {
holder.innerHTML = isValid
? '<i data-lucide="check" style="width:14px;height:14px;"></i>'
: '<i data-lucide="x" style="width:14px;height:14px;"></i>';
if (typeof lucide !== 'undefined') {
lucide.createIcons({ root: holder });
}
}
};
updateReq('req-length', statuses.hasLength);
updateReq('req-upper', statuses.hasUpper);
updateReq('req-number', statuses.hasNumber);
updateReq('req-special', statuses.hasSpecial);
updateReq('req-match', statuses.matches);
}
};
+1839
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

+306
View File
@@ -0,0 +1,306 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="334px" height="292px" viewBox="0 0 334 292" enable-background="new 0 0 334 292" xml:space="preserve"> <image id="image0" width="334" height="292" x="0" y="0"
xlink:href="data:image/png;base64,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" />
</svg>

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+306
View File
@@ -0,0 +1,306 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="334px" height="292px" viewBox="0 0 334 292" enable-background="new 0 0 334 292" xml:space="preserve"> <image id="image0" width="334" height="292" x="0" y="0"
xlink:href="data:image/png;base64,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" />
</svg>

After

Width:  |  Height:  |  Size: 23 KiB

+12
View File
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Agora",
"short_name": "Agora",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#1a1a1a",
"theme_color": "#1a1a1a",
"icons": [
{
"src": "images/icone-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "images/icone-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+2
View File
File diff suppressed because one or more lines are too long
+1173
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
const CACHE_NAME = 'Agora-v1.5.2';
const STATIC_ASSETS = [
'/style.css',
'/app.auth.js',
'/app.js',
'/manifest.json',
'/images/icone-192.png',
'/images/icone-512.png',
'/images/logo_transparent.png',
'/lucide.min.js'
];
// 1. INSTALLATION
self.addEventListener('install', event => {
self.skipWaiting();
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
console.log('[SW] Mise en cache des fichiers statiques');
return cache.addAll(STATIC_ASSETS);
})
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cache => {
if (cache !== CACHE_NAME) {
console.log('[SW] Suppression ancien cache:', cache);
return caches.delete(cache);
}
})
);
})
);
return self.clients.claim();
});
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
const isStaticAsset = url.pathname.endsWith('.css') ||
url.pathname.endsWith('.js') ||
url.pathname.endsWith('.png') ||
url.pathname.endsWith('.json');
if (!isStaticAsset || event.request.method !== 'GET' || !url.protocol.startsWith('http')) {
return;
}
// Stratégie Network-First : on tente le réseau, et on met à jour le cache. Sinon, fallback sur le cache.
event.respondWith(
fetch(event.request)
.then(response => {
if (response && response.status === 200) {
const responseCopy = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, responseCopy);
});
}
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
});
+48
View File
@@ -0,0 +1,48 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { sourceRegistry } from './registry.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Scanne le dossier plugins.
* Chaque fichier s'auto-enregistre dans le registry au chargement du module.
* Ensuite, les health checks sont lancés pour ne garder que les sources fonctionnelles.
*/
export async function discoverSources(): Promise<void> {
const pluginsDir = path.join(__dirname, '../../plugins');
console.log(`[Discovery] Scan du dossier plugins/... (${pluginsDir})`);
if (!fs.existsSync(pluginsDir)) {
console.warn('[Discovery] Aucun dossier plugins trouvé.');
return;
}
const pluginFolders = fs.readdirSync(pluginsDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
if (!pluginFolders.length) {
console.warn('[Discovery] Aucun plugin trouvé.');
return;
}
for (const folder of pluginFolders) {
// En ES modules TypeScript compile, ce sera index.js (ou index.ts si on utilise ts-node)
// On essaie d'importer le dossier directement, Node (avec moduleResolution: NodeNext)
// ou la configuration devrait trouver le index.js s'il est là.
// Plus sûr : importer explicitement le fichier index.js
const indexPath = pathToFileURL(path.join(pluginsDir, folder, 'index.js')).href;
try {
await import(indexPath);
console.log(`[Discovery] 📦 Plugin ${folder} chargé`);
} catch (err: any) {
console.error(`[Discovery] ⚠️ Erreur chargement plugin ${folder}:`, err.message);
}
}
// Lance les health checks et ne garde que les sources fonctionnelles
await sourceRegistry.initialize();
}
+88
View File
@@ -0,0 +1,88 @@
import { ISource } from '../types/source.js';
/**
* Les fichiers sources s'auto-enregistrent via `register()` au chargement du module
* `initialize()` lance les health checks et ne garde que les sources fonctionnelles
* Les routes et le state n'interagissent qu'avec les sources actives
*/
class SourceRegistry {
private pending: ISource[] = [];
private active = new Map<string, ISource>();
private allRegistered = new Map<string, ISource>();
/**
* Enregistre une source dans la file d'attente.
* Appelé automatiquement par chaque fichier source au chargement.
*/
register(source: ISource) {
this.pending.push(source);
this.allRegistered.set(source.name, source);
}
/**
* Retourne toutes les sources enregistrées (actives ou inactives).
*/
getAllRegistered(): ISource[] {
return Array.from(this.allRegistered.values());
}
/**
* Lance les health checks sur toutes les sources enregistrées (en parallèle)
* Seules les sources qui passent le check sont dites"active"
*/
async initialize(): Promise<void> {
const sourcesToTest = Array.from(this.allRegistered.values());
console.log(`[Registry] Lancement des health checks sur ${sourcesToTest.length} source(s)...`);
this.active.clear();
this.pending = [];
const results = await Promise.allSettled(
sourcesToTest.map(async (source) => {
const healthy = await source.healthCheck();
return { source, healthy };
})
);
for (const result of results) {
if (result.status === 'fulfilled') {
const { source, healthy } = result.value;
if (healthy) {
this.active.set(source.name, source);
console.log(`[Registry] ✅ ${source.name.toUpperCase()} — opérationnelle`);
} else {
console.warn(`[Registry] ❌ ${source.name.toUpperCase()} — non disponible`);
}
} else {
console.error(`[Registry] ❌ Health check crash:`, result.reason);
}
}
const names = this.getAvailableNames();
console.log(`[Registry] ${this.active.size} source(s) active(s): ${names.length ? names.map(n => n.toUpperCase()).join(', ') : 'Aucune'}`);
}
get(name: string): ISource | null {
return this.active.get(name) || null;
}
getAll(): ISource[] {
return Array.from(this.active.values());
}
getAvailableNames(): string[] {
return Array.from(this.active.keys());
}
getDefault(): ISource | null {
const first = this.active.values().next();
return first.done ? null : first.value;
}
has(name: string): boolean {
return this.active.has(name);
}
}
export const sourceRegistry = new SourceRegistry();
+156
View File
@@ -0,0 +1,156 @@
import './utils/logger.js';
import express from 'express';
import session from 'express-session';
import connectSessionFileStore from 'session-file-store';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const FileStore = connectSessionFileStore(session);
import { CONFIG } from './utils/config.js';
import { globalState, checkSiteStatus } from './utils/state.js';
import { discoverSources } from './core/discovery.js';
import { sourceRegistry } from './core/registry.js';
import { hasAnyUser, createUser } from './utils/userStore.js';
import authRoutes from './routes/auth.js';
import apiRoutes from './routes/api.js';
import jdRoutes from './routes/jd.js';
import proxyRoutes from './routes/proxy.js';
import viewRoutes from './routes/views.js';
import setupRoutes from './routes/setup.js';
const app = express();
const PORT = CONFIG.PORT;
// Configuration EJS
app.set('view engine', 'ejs');
app.set('views', path.join(process.cwd(), 'views'));
// Inject appVersion in all views
import fs from 'fs';
const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));
app.locals.appVersion = packageJson.version;
// ========================= MIDDLEWARES SÉCURITÉ =========================
// CORS : désactivé (app self-hosted, pas besoin de cross-origin)
// Remplace l'ancien cors() ouvert qui permettait tout origin
app.use(helmet({
contentSecurityPolicy: {
directives: {
"default-src": ["'self'"],
"script-src": ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
"script-src-attr": ["'unsafe-inline'"],
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
"font-src": ["'self'", "https://fonts.gstatic.com"],
"img-src": ["'self'", "data:", "blob:", "https://www.google.com", "https://*.gstatic.com"],
"connect-src": ["'self'"],
"upgrade-insecure-requests": null,
}
},
crossOriginResourcePolicy: { policy: "cross-origin" }
}));
// Body parser avec limite de taille — anti DoS mémoire
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: false, limit: '1mb' }));
app.set('trust proxy', 1);
app.use(cookieParser());
// Session avec sameSite: 'lax' — anti CSRF
app.use(session({
store: new FileStore({
path: './sessions',
ttl: 48 * 60 * 60,
retries: 10,
reapInterval: 3600
}),
secret: CONFIG.SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: 'auto',
sameSite: 'lax',
maxAge: 48 * 60 * 60 * 1000
}
}));
// ========================= ROUTES =========================
// Setup route (doit être AVANT le gatekeeper dans viewRoutes)
app.use('/setup', setupRoutes);
// Routeur de vues EJS (inclut le setup gatekeeper)
app.use('/', viewRoutes);
// Enregistrement des routes API
app.use('/', authRoutes);
app.use('/', apiRoutes);
app.use('/', jdRoutes);
app.use('/', proxyRoutes);
// Serve frontend static files
app.use(express.static(path.join(process.cwd(), 'public')));
// ========================= DÉMARRAGE =========================
app.listen(PORT, async () => {
console.log(`\n${'='.repeat(60)}`);
console.log(` Agora — API Server`);
console.log(`${'='.repeat(60)}`);
console.log(`Serveur API démarré sur http://localhost:${PORT}\n`);
// ---- Bootstrap auto du premier admin via .env ----
if (!hasAnyUser()) {
if (CONFIG.ADMIN_USERNAME && CONFIG.ADMIN_PASSWORD) {
try {
createUser(CONFIG.ADMIN_USERNAME, CONFIG.ADMIN_PASSWORD, 'admin');
console.log(`[Auth] ✅ Admin auto-créé depuis .env: "${CONFIG.ADMIN_USERNAME}"`);
} catch (error: any) {
console.error(`[Auth] ❌ Erreur création admin auto:`, error.message);
}
} else {
console.log(`[Auth] ⚠️ Aucun utilisateur trouvé. Accédez à http://localhost:${PORT}/setup pour créer le premier admin.`);
}
}
// Auto-discovery : scan sources/, import, health check
await discoverSources();
// Active les sources sauvegardées ou toutes les sources par défaut
const { loadSettings } = await import('./utils/settingsManager.js');
const settings = loadSettings();
const availableSources = sourceRegistry.getAvailableNames();
if (settings && settings.activeSources && Array.isArray(settings.activeSources)) {
// Filtre pour ne garder que les sources qui sont toujours valides/enregistrées
globalState.activeSources = settings.activeSources.filter((s: string) => availableSources.includes(s));
} else {
// Aucune sauvegarde trouvée, on active tout par défaut
globalState.activeSources = availableSources;
}
console.log(`\nSource(s) par défaut: ${globalState.activeSources.map(s => s.toUpperCase()).join(', ') || 'Aucune'}`);
const scheduleNextCheck = () => {
const randomMinutes = Math.floor(Math.random() * (CONFIG.MAX_MINUTES - CONFIG.MIN_MINUTES + 1)) + CONFIG.MIN_MINUTES;
console.log(`[Timer] Prochaine vérification dans ${randomMinutes} minutes.`);
setTimeout(async () => { await checkSiteStatus(); scheduleNextCheck(); }, randomMinutes * 60 * 1000);
};
console.log("Lancement de la première vérification...");
await checkSiteStatus();
scheduleNextCheck();
});
process.on('SIGINT', () => { console.log('\nArrêt SIGINT...'); process.exit(0); });
process.on('SIGTERM', () => { console.log('\nArrêt SIGTERM...'); process.exit(0); });
+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;
+130
View File
@@ -0,0 +1,130 @@
import express from 'express';
import { verifyCredentials, changeUserPassword } from '../utils/userStore.js';
import apiLimiter, { loginLimiter } from '../utils/rateLimiter.js';
import authMiddleware from '../utils/authMiddleware.js';
const router = express.Router();
// ============================================================
// POST /login — Authentification username + password
// ============================================================
router.post('/api/login', loginLimiter, (req, res) => {
const { username, password } = req.body;
// Validation basique des inputs
if (!username || typeof username !== 'string') {
return res.status(400).json({ error: "Nom d'utilisateur manquant." });
}
if (!password || typeof password !== 'string') {
return res.status(400).json({ error: "Mot de passe manquant." });
}
// Limiter la taille du password pour éviter un DoS scrypt (max 128 chars)
if (password.length > 128) {
return res.status(400).json({ error: "Mot de passe trop long." });
}
try {
const user = verifyCredentials(username, password);
if (user) {
// Session regeneration — anti session fixation
const oldSession = req.session as any;
req.session.regenerate((err) => {
if (err) {
console.error('[Auth] Erreur session.regenerate:', err);
return res.status(500).json({ error: "Erreur interne du serveur." });
}
// Stocker les infos user dans la nouvelle session
(req.session as any).user = {
id: user.id,
username: user.username,
role: user.role,
loginAt: new Date().toISOString(),
mustChangePassword: user.mustChangePassword || false,
preferences: user.preferences || {},
};
console.log(`[Auth] Connexion réussie: ${user.username} (${user.role}) depuis ${req.ip}`);
res.json({
success: true,
mustChangePassword: user.mustChangePassword || false,
user: { username: user.username, role: user.role }
});
});
} else {
// Message générique — anti user-enumeration (jamais "utilisateur inconnu" vs "mauvais mdp")
setTimeout(() => {
console.warn(`[Auth] Tentative échouée pour "${username}" depuis ${req.ip}`);
res.status(401).json({ error: "Identifiants invalides." });
}, 500);
}
} catch (e) {
console.error('[Auth] Erreur login:', e);
res.status(500).json({ error: "Erreur interne du serveur." });
}
});
// ============================================================
// POST /change-password — Changement de mot de passe (obligatoire ou non)
// ============================================================
router.post('/api/change-password', apiLimiter, authMiddleware, (req, res) => {
const session = req.session as any;
const { newPassword } = req.body;
if (!newPassword || typeof newPassword !== 'string') {
return res.status(400).json({ error: "Le nouveau mot de passe est obligatoire." });
}
try {
changeUserPassword(session.user.id, newPassword);
// Mettre à jour la session
session.user.mustChangePassword = false;
// Important: Mettre à jour loginAt car le passwordChangedAt a changé, pour éviter d'invalider la session
session.user.loginAt = new Date().toISOString();
console.log(`[Auth] Mot de passe changé avec succès pour ${session.user.username}`);
res.json({ success: true, message: "Mot de passe modifié avec succès." });
} catch (error: any) {
res.status(400).json({ error: error.message });
}
});
// ============================================================
// GET /check-session — Vérifie l'état de la session
// ============================================================
router.get('/api/check-session', apiLimiter, (req, res) => {
const session = req.session as any;
if (session.user) {
res.json({
isLoggedIn: true,
user: {
username: session.user.username,
role: session.user.role,
mustChangePassword: session.user.mustChangePassword || false,
preferences: session.user.preferences || {},
}
});
} else {
res.json({ isLoggedIn: false });
}
});
// ============================================================
// POST /logout — Déconnexion
// ============================================================
router.post('/api/logout', apiLimiter, (req, res) => {
req.session.destroy(err => {
if (err) return res.status(500).json({ error: "Échec de la déconnexion." });
res.clearCookie('connect.sid');
res.json({ success: true });
});
});
export default router;
+38
View File
@@ -0,0 +1,38 @@
import express from 'express';
import { sendToJDownloader } from '../utils/jdownloader.js';
import authMiddleware from '../utils/authMiddleware.js';
import apiLimiter from '../utils/rateLimiter.js';
const router = express.Router();
router.post('/api/jd/add', apiLimiter, authMiddleware, async (req, res) => {
const { link, links, packageName, isSeries } = req.body;
let list: string[] = [];
if (Array.isArray(links)) {
list = links.map(l => l.trim()).filter(Boolean);
} else if (typeof links === 'string') {
list = links.split('\n').map(l => l.trim()).filter(Boolean);
}
if (link && typeof link === 'string') {
const trimmed = link.trim();
if (trimmed && !list.includes(trimmed)) {
list.push(trimmed);
}
}
if (list.length === 0) {
return res.status(400).json({ error: "Aucun lien valide fourni." });
}
try {
for (const url of list) {
await sendToJDownloader(url, packageName || 'Manual Add', !!isSeries);
}
res.json({ success: true, count: list.length });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;
+60
View File
@@ -0,0 +1,60 @@
import express from 'express';
import authMiddleware from '../utils/authMiddleware.js';
import apiLimiter from '../utils/rateLimiter.js';
const router = express.Router();
function isBlockedHost(hostname: string) {
if (hostname === 'localhost' || hostname.endsWith('.local')) return true;
if (hostname === '[::1]' || hostname === '::1') return true;
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const match = hostname.match(ipv4Regex);
if (match) {
const p1 = parseInt(match[1]);
const p2 = parseInt(match[2]);
if (p1 === 10) return true; // 10.x.x.x
if (p1 === 127) return true; // 127.x.x.x
if (p1 === 192 && p2 === 168) return true; // 192.168.x.x
if (p1 === 172 && p2 >= 16 && p2 <= 31) return true; // 172.16.x.x - 172.31.x.x
if (p1 === 169 && p2 === 254) return true; // APIPA
if (p1 === 0) return true; // 0.0.0.0
}
return false;
}
router.get('/api/proxy-image', apiLimiter, authMiddleware, async (req, res) => {
const { url } = req.query;
if (!url || typeof url !== 'string') return res.status(400).send('URL manquante ou invalide');
try {
const parsedUrl = new URL(url);
// 1. Vérification du protocole
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return res.status(403).send('Protocole non autorisé');
}
// 2. Vérification de l'hôte (Black Liste IPs privées / localhost)
const hostname = parsedUrl.hostname.toLowerCase();
if (isBlockedHost(hostname)) {
console.warn(`[Proxy] Tentative bloquée (SSRF) pour l'hôte local ou privé : ${hostname}`);
return res.status(403).send('Hôte non autorisé pour le proxy');
}
const response = await fetch(url);
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const contentType = response.headers.get('content-type') || 'image/jpeg';
res.set('Content-Type', contentType);
res.set('Cache-Control', 'public, max-age=86400'); // 24h cache
res.send(buffer);
} catch (error: any) {
res.status(500).send('Erreur lors du chargement de l\'image');
}
});
export default router;
+67
View File
@@ -0,0 +1,67 @@
import express from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { hasAnyUser, createUser } from '../utils/userStore.js';
import { configManager, CONFIG, getPluginsToConfigure } from '../utils/config.js';
const router = express.Router();
router.get('/', (req, res) => {
if (hasAnyUser()) {
return res.redirect('/login');
}
const pluginsToConfigure = getPluginsToConfigure();
res.render('setup', { error: null, pluginsToConfigure });
});
router.post('/', (req, res) => {
if (hasAnyUser()) {
return res.redirect('/login');
}
const { username, password, confirmPassword, config } = req.body;
if (!username || !password) {
return res.render('setup', { error: "Tous les champs sont requis.", pluginsToConfigure: getPluginsToConfigure() });
}
if (password !== confirmPassword) {
return res.render('setup', { error: "Les mots de passe ne correspondent pas.", pluginsToConfigure: getPluginsToConfigure() });
}
try {
if (config) {
for (const [key, value] of Object.entries(config)) {
if (value && typeof value === 'string' && value.trim() !== '') {
configManager.set(key, value.trim());
}
}
configManager.save();
}
const { user } = createUser(username, password, 'admin');
req.session.regenerate((err) => {
if (err) {
console.error('[Setup] Erreur session.regenerate:', err);
return res.render('setup', { error: "Erreur interne. Réessayez.", pluginsToConfigure: getPluginsToConfigure() });
}
(req.session as any).user = {
id: user.id,
username: user.username,
role: user.role,
loginAt: new Date().toISOString(),
};
console.log(`[Setup] Premier admin créé: ${user.username}`);
res.redirect('/');
});
} catch (error: any) {
console.error('[Setup] Erreur création admin:', error.message);
res.render('setup', { error: error.message, pluginsToConfigure: getPluginsToConfigure() });
}
});
export default router;
+99
View File
@@ -0,0 +1,99 @@
import express from 'express';
import { viewAuthMiddleware, viewRequireAdmin } from '../utils/authMiddleware.js';
import { hasAnyUser } from '../utils/userStore.js';
const router = express.Router();
// ============================================================
// Setup Gatekeeper — Redirige vers /setup si aucun user n'existe
// ============================================================
const setupGatekeeper = (req: express.Request, res: express.Response, next: express.NextFunction) => {
// Laisser passer les routes de setup et les assets statiques
if (req.path === '/setup' || req.path.startsWith('/setup/') ||
req.path === '/login' || req.path.startsWith('/login') ||
req.path.startsWith('/images') || req.path.startsWith('/style') ||
req.path.startsWith('/app.') || req.path.startsWith('/lucide') ||
req.path.startsWith('/sw.') || req.path.startsWith('/manifest')) {
return next();
}
if (!hasAnyUser()) {
return res.redirect('/setup');
}
next();
};
// Appliquer le gatekeeper à toutes les routes de vue
router.use(setupGatekeeper);
// ============================================================
// Routes publiques
// ============================================================
router.get('/login', (req, res) => {
if ((req.session as any).user) {
const defaultPage = req.cookies?.defaultPage || '/trending';
const allowedPages = ['/trending', '/recent', '/search', '/downloads', '/manual', '/settings'];
if (allowedPages.includes(defaultPage)) {
res.redirect(defaultPage);
} else {
res.redirect('/trending');
}
} else {
res.render('login');
}
});
// ============================================================
// Routes protégées — Injecter les infos user dans les vues
// ============================================================
const protectedRoute = (viewName: string, page: string) => {
return [viewAuthMiddleware, (req: express.Request, res: express.Response) => {
const session = req.session as any;
res.render(viewName, {
page,
currentUser: session.user || null,
});
}] as express.RequestHandler[];
};
router.get('/trending', ...protectedRoute('trending', 'trending'));
router.get('/recent', ...protectedRoute('recent', 'recent'));
router.get('/search', ...protectedRoute('search', 'search'));
router.get('/downloads', ...protectedRoute('downloads', 'downloads'));
router.get('/manual', ...protectedRoute('manual', 'manual'));
router.get('/settings', viewAuthMiddleware, viewRequireAdmin, async (req, res) => {
const session = req.session as any;
const { getPluginsToConfigure, CONFIG } = await import('../utils/config.js');
res.render('settings', {
page: 'settings',
currentUser: session.user || null,
pluginsToConfigure: getPluginsToConfigure(),
preferredHosters: CONFIG.PREFERRED_HOSTERS
});
});
// Admin endpoint redirects to settings
router.get('/admin', viewAuthMiddleware, (req, res) => {
res.redirect('/settings');
});
// Home endpoint redirects depending on authentication status
router.get('/', (req, res) => {
if ((req.session as any).user) {
const defaultPage = req.cookies?.defaultPage || '/trending';
const allowedPages = ['/trending', '/recent', '/search', '/downloads', '/manual', '/settings'];
if (allowedPages.includes(defaultPage)) {
res.redirect(defaultPage);
} else {
res.redirect('/trending');
}
} else {
res.redirect('/login');
}
});
export default router;
+74
View File
@@ -0,0 +1,74 @@
export type MediaType = 'movie' | 'series' | 'anime' | 'book' | 'game' | 'software' | 'music' | 'other';
export interface SearchResult {
title: string;
year: string | null;
image: string | null;
hrefPath: string; // The identifier or path for the source
type: MediaType;
source: string; // 'zt' | 'hydracker'
hydrackerId?: string; // Specific to Hydracker
}
export interface VideoLink {
id: string | number;
host: string;
label?: string;
url: string | null; // Final direct URL if available
size?: string;
sizeBytes?: number;
quality?: string;
langs?: string[];
subs?: string[];
releaseName?: string;
episode?: string | null;
}
export interface ContentLinks {
links: VideoLink[];
releaseNames?: string[];
relatedSeasons?: { href: string; label: string }[];
relatedQualities?: { href: string; label: string }[];
}
export interface SeasonOption {
label: string;
value: string | number;
}
export interface SelectionData {
links: VideoLink[];
seasons: SeasonOption[];
isSeries: boolean;
}
export interface ISource {
name: string;
displayName?: string;
search(query: string, mediaType?: MediaType): Promise<SearchResult[]>;
getTrending(mediaType: MediaType): Promise<SearchResult[]>;
getRecent?(): Promise<SearchResult[]>;
getContentLinks(identifier: string, season?: number): Promise<ContentLinks>;
/**
* Vérifie si la source est utilisable (config valide + connectivité).
* Appelé au démarrage par le registry. Seules les sources qui retournent true sont activées.
*/
healthCheck(): Promise<boolean>;
// Unified selection method
getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData>;
/**
* Résout un lien vers son URL finale téléchargeable.
* Implémenté par les sources qui nécessitent une résolution en 2 étapes
* (ex: Hydracker l'ID doit être résolu via une API premium).
* Les sources avec des URLs directes (ex: ZT) n'ont pas besoin de l'implémenter.
*/
resolveLink?(linkId: string): Promise<string | null>;
// Optional methods that might be source-specific but useful to standardize
getSeasons?(identifier: string): Promise<number[]>;
getEpisodes?(identifier: string, season: number): Promise<any[]>;
}
+106
View File
@@ -0,0 +1,106 @@
import { Request, Response, NextFunction } from 'express';
import { getUserById } from './userStore.js';
// ============================================================
// Auth Middleware — Session-based avec vérification passwordChangedAt
// ============================================================
/**
* Middleware d'authentification.
* Vérifie que l'utilisateur est connecté ET que sa session n'a pas é
* invalidée par un changement de mot de passe.
*/
const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
const session = req.session as any;
if (!session.user || !session.user.id) {
return res.status(401).json({ error: "Non autorisé. Veuillez vous connecter." });
}
// Bloquer les APIs si changement de mot de passe requis
if (session.user.mustChangePassword && req.path !== '/api/change-password' && req.path !== '/api/logout') {
return res.status(403).json({ error: "Changement de mot de passe obligatoire.", mustChangePassword: true });
}
// Vérifier que le user existe encore et que le password n'a pas changé depuis le login
const user = getUserById(session.user.id);
if (!user) {
// User supprimé depuis le login → détruire la session
session.destroy(() => {});
return res.status(401).json({ error: "Session invalide. Veuillez vous reconnecter." });
}
// Comparer loginAt avec passwordChangedAt — si le password a changé après le login, invalider
if (session.user.loginAt && user.passwordChangedAt) {
const loginTime = new Date(session.user.loginAt).getTime();
const passwordChangeTime = new Date(user.passwordChangedAt).getTime();
if (passwordChangeTime > loginTime) {
session.destroy(() => {});
return res.status(401).json({ error: "Votre mot de passe a été modifié. Veuillez vous reconnecter." });
}
}
next();
};
/**
* Middleware admin.
* Doit être utilisé APRÈS authMiddleware (session.user déjà vérifié).
* Vérifie que l'utilisateur a le rôle admin.
*/
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
const session = req.session as any;
if (!session.user || session.user.role !== 'admin') {
return res.status(403).json({ error: "Accès refusé. Droits administrateur requis." });
}
next();
};
/**
* Middleware de vue d'authentification.
* Identique à authMiddleware mais redirige vers /login au lieu de retourner du JSON.
*/
export const viewAuthMiddleware = (req: Request, res: Response, next: NextFunction) => {
const session = req.session as any;
if (!session.user || !session.user.id) {
return res.redirect('/login');
}
// Même vérification passwordChangedAt
const user = getUserById(session.user.id);
if (!user) {
session.destroy(() => {});
return res.redirect('/login');
}
if (session.user.loginAt && user.passwordChangedAt) {
const loginTime = new Date(session.user.loginAt).getTime();
const passwordChangeTime = new Date(user.passwordChangedAt).getTime();
if (passwordChangeTime > loginTime) {
session.destroy(() => {});
return res.redirect('/login');
}
}
next();
};
/**
* Middleware de vue exigeant le rôle admin.
* Redirige les non-admins vers /trending en déposant un cookie d'erreur.
*/
export const viewRequireAdmin = (req: Request, res: Response, next: NextFunction) => {
const session = req.session as any;
if (!session.user || session.user.role !== 'admin') {
res.cookie('authError', "Accès refusé. Droits administrateur requis.", { maxAge: 10000 });
return res.redirect('/trending');
}
next();
};
export default authMiddleware;
+198
View File
@@ -0,0 +1,198 @@
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
dotenv.config();
const DEV_SECRET = 'agora-secret-key-12345';
const isProd = process.env.NODE_ENV === 'production';
if (isProd && (!process.env.SECRET || process.env.SECRET === DEV_SECRET)) {
throw new Error('[CONFIG] SECRET requis et différent du fallback dev en production.');
}
const CONFIG_PATH = path.resolve(process.cwd(), 'database', 'config.json');
const OLD_CONFIG_PATH = path.resolve(process.cwd(), 'config.json');
export class ConfigurationManager {
private dynamicConfig: Record<string, any> = {};
constructor() {
this.load();
}
public load() {
// Migration: si un fichier de config existe à la racine, on le déplace dans database/ pour plus de sécurité
if (fs.existsSync(OLD_CONFIG_PATH) && !fs.existsSync(CONFIG_PATH)) {
try {
const dbDir = path.resolve(process.cwd(), 'database');
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
fs.copyFileSync(OLD_CONFIG_PATH, CONFIG_PATH);
fs.unlinkSync(OLD_CONFIG_PATH);
console.log('[CONFIG] Migration de config.json vers database/ réussie.');
} catch (e) {
console.error('[CONFIG] Erreur de migration de config.json', e);
}
}
if (fs.existsSync(CONFIG_PATH)) {
try {
const data = fs.readFileSync(CONFIG_PATH, 'utf-8');
this.dynamicConfig = JSON.parse(data);
} catch (e) {
console.error('[CONFIG] Erreur de lecture de database/config.json', e);
}
}
}
public save() {
try {
const dbDir = path.dirname(CONFIG_PATH);
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(this.dynamicConfig, null, 2), 'utf-8');
} catch (e) {
console.error('[CONFIG] Erreur d\'écriture de database/config.json', e);
}
}
public get(key: string, fallback?: any): any {
if (this.dynamicConfig[key] !== undefined && this.dynamicConfig[key] !== '') {
return this.dynamicConfig[key];
}
if (process.env[key] !== undefined && process.env[key] !== '') {
return process.env[key];
}
return fallback;
}
public set(key: string, value: any) {
this.dynamicConfig[key] = value;
}
public delete(key: string) {
delete this.dynamicConfig[key];
}
public getAllPluginsConfig(): Record<string, any> {
// Renvoie tout sauf les variables systèmes pour l'UI
const exclude = ['PORT', 'SECRET', 'ADMIN_USERNAME', 'ADMIN_PASSWORD'];
const result: Record<string, any> = {};
for (const [k, v] of Object.entries(this.dynamicConfig)) {
if (!exclude.includes(k)) {
result[k] = v;
}
}
return result;
}
}
export const configManager = new ConfigurationManager();
export const CONFIG = new Proxy({}, {
get: (target, prop) => {
if (typeof prop !== 'string') return undefined;
switch(prop) {
case 'HYDRACKER_URL': return configManager.get('HYDRACKER_URL', process.env.BASE_URL || '');
case 'HYDRACKER_API_KEY': return configManager.get('HYDRACKER_API_KEY', process.env.API_KEY || '');
case 'HYDRACKER_TIMEOUT': return parseInt(configManager.get('HYDRACKER_TIMEOUT', '30000'), 10);
case 'TMDB_ENABLED': return String(configManager.get('TMDB_ENABLED', 'false')) === 'true';
case 'TMDB_API_KEY': return configManager.get('TMDB_API_KEY', '');
case 'ZT_URL': return configManager.get('ZT_URL', '');
case 'ZTTEAM_URL': return configManager.get('ZTTEAM_URL', '');
case 'FT_URL': return configManager.get('FT_URL', '');
case 'FS24_URL': return configManager.get('FS24_URL', '');
case 'FS24_USERNAME': return configManager.get('FS24_USERNAME', '');
case 'FS24_PASSWORD': return configManager.get('FS24_PASSWORD', '');
case 'MOVIX_URL': return configManager.get('MOVIX_URL', '');
case 'FLIXART_URL': return configManager.get('FLIXART_URL', '');
case 'FLIXART_USERNAME': return configManager.get('FLIXART_USERNAME', '');
case 'FLIXART_PASSWORD': return configManager.get('FLIXART_PASSWORD', '');
case 'LOADIX_URL': return configManager.get('LOADIX_URL', '');
case 'DB_PATH': return configManager.get('DB_PATH', './database/darkiworld.db');
case 'JD_HOST': return configManager.get('JD_HOST')?.trim();
case 'JD_API_PORT': return String(configManager.get('JD_API_PORT') || '').trim() || undefined;
case 'JD_CREATE_SUBFOLDER': return String(configManager.get('JD_CREATE_SUBFOLDER')) === 'true';
case 'JD_AUTOSTART': return String(configManager.get('JD_AUTOSTART')) === 'true';
case 'JD_FORCED_START': return String(configManager.get('JD_FORCED_START')) === 'true';
case 'SECRET': return configManager.get('SECRET', DEV_SECRET);
case 'MIN_MINUTES': return parseInt(configManager.get('MIN_MINUTES', '15'), 10);
case 'MAX_MINUTES': return parseInt(configManager.get('MAX_MINUTES', '30'), 10);
case 'PORT': return parseInt(configManager.get('PORT', '3067'), 10);
case 'PREFERRED_HOSTERS': {
const val = configManager.get('PREFERRED_HOSTERS');
if (Array.isArray(val)) return val.filter(h => h.toLowerCase() !== 'uptobox');
if (typeof val === 'string') return val.split(',').map(s => s.trim()).filter(h => h.toLowerCase() !== 'uptobox');
return ['1fichier', 'turbobit', 'rapidgator', 'nitroflare', 'ddownload', 'mega', 'gofile', 'pixeldrain'];
}
case 'MAX_RESULTS_PER_SOURCE': return parseInt(configManager.get('MAX_RESULTS_PER_SOURCE', '20'), 10);
default: return configManager.get(prop);
}
}
}) as any;
export function getPluginsToConfigure() {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const pluginsDir = path.join(__dirname, '../../plugins');
let detected: string[] = [];
if (fs.existsSync(pluginsDir)) {
detected = fs.readdirSync(pluginsDir, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name);
}
const pluginConfigMap: Record<string, { key: string; label: string; placeholder: string; default?: string }[]> = {
'ZT': [{ key: 'ZT_URL', label: 'Zone-Téléchargement URL', placeholder: 'https://...', default: CONFIG.ZT_URL }],
'ztnews': [{ key: 'ZTTEAM_URL', label: 'ZT (Team) URL', placeholder: 'https://...', default: CONFIG.ZTTEAM_URL }],
'freetelecharger': [{ key: 'FT_URL', label: 'Free-Télécharger URL', placeholder: 'https://...', default: CONFIG.FT_URL }],
'fs24': [
{ key: 'FS24_URL', label: 'FS24 URL', placeholder: 'https://...', default: CONFIG.FS24_URL },
{ key: 'FS24_USERNAME', label: 'FS24 Identifiant', placeholder: 'Nom d\'utilisateur...', default: CONFIG.FS24_USERNAME },
{ key: 'FS24_PASSWORD', label: 'FS24 Mot de passe', placeholder: 'Mot de passe...', default: CONFIG.FS24_PASSWORD }
],
'movix': [
{ key: 'MOVIX_URL', label: 'Movix URL', placeholder: 'https://...', default: CONFIG.MOVIX_URL }
],
'flixart': [
{ key: 'FLIXART_URL', label: 'FlixArt URL', placeholder: 'https://...', default: CONFIG.FLIXART_URL },
{ key: 'FLIXART_USERNAME', label: 'FlixArt Identifiant', placeholder: 'Nom d\'utilisateur...', default: CONFIG.FLIXART_USERNAME },
{ key: 'FLIXART_PASSWORD', label: 'FlixArt Mot de passe', placeholder: 'Mot de passe...', default: CONFIG.FLIXART_PASSWORD }
],
'loadix': [
{ key: 'LOADIX_URL', label: 'Loadix API URL', placeholder: 'https://...', default: CONFIG.LOADIX_URL }
]
};
const configs = detected.filter(p => pluginConfigMap[p]).map(p => ({
name: p,
fields: pluginConfigMap[p]
}));
configs.push({
name: 'Paramètres Généraux',
fields: [
{ key: 'MAX_RESULTS_PER_SOURCE', label: 'Limite de résultats par source (Tendances/Récents)', placeholder: 'ex: 20', default: String(CONFIG.MAX_RESULTS_PER_SOURCE) },
{ key: 'JD_FORCED_START', label: 'JDownloader : Démarrage Forcé (ignore la file d\'attente)', placeholder: 'true ou false', default: String(CONFIG.JD_FORCED_START === true) }
]
});
configs.push({
name: 'TMDB (Enrichissement Auto)',
fields: [
{ key: 'TMDB_ENABLED', label: 'Activer TMDB', placeholder: 'true ou false', default: String(CONFIG.TMDB_ENABLED === true) },
{ key: 'TMDB_API_KEY', label: 'Clé API TMDB (v3 auth)', placeholder: 'Clé API...', default: CONFIG.TMDB_API_KEY }
]
});
return configs;
}
+67
View File
@@ -0,0 +1,67 @@
import { CONFIG } from './config.js';
import * as fs from 'fs';
import * as path from 'path';
// --- JDownloader ---
export async function sendToJDownloader(link: string, titleName: string, isSeries: boolean = false) {
if (!CONFIG.PATHS_JD_WATCH) {
console.error("Erreur JDownloader: PATHS_JD_WATCH non configuré.");
return;
}
if (!CONFIG.PATHS_JD_FILMS) {
console.error("Erreur JDownloader: PATHS_JD_FILMS non configuré.");
return;
}
if (!CONFIG.PATHS_JD_SERIES) {
console.error("Erreur JDownloader: PATHS_JD_SERIES non configuré.");
return;
}
const fileName = `link_${Date.now()}.crawljob`;
const filePath = path.join(CONFIG.PATHS_JD_WATCH, fileName);
const lineEnding = '\n';
const safeLink = link.trim() + "#movie.mkv";
const autoStartStr = CONFIG.JD_AUTOSTART ? 'TRUE' : 'FALSE';
const forcedStartStr = CONFIG.JD_FORCED_START ? 'TRUE' : 'FALSE';
let fileContent = `text=${safeLink}${lineEnding}`;
fileContent += `enabled=TRUE${lineEnding}`;
fileContent += `autoStart=${autoStartStr}${lineEnding}`;
fileContent += `forcedStart=${forcedStartStr}${lineEnding}`;
fileContent += `deepAnalyse=TRUE${lineEnding}`;
fileContent += `autoConfirm=TRUE${lineEnding}`;
fileContent += `overwritePackagizerEnabled=TRUE${lineEnding}`;
if (titleName) {
const safeTitle = titleName.replace(/[\r\n<>:"/\\|?*]+/g, '').replace(/\.$/, '').trim();
fileContent += `packageName=${safeTitle}${lineEnding}`;
if (isSeries) {
console.log(`Série (${titleName}), configuration chemin JD...`);
const rawSeriesFolder = CONFIG.JD_CREATE_SUBFOLDER ? `${CONFIG.PATHS_JD_SERIES}${safeTitle}` : `${CONFIG.PATHS_JD_SERIES}`;
const seriesDownloadFolder = rawSeriesFolder.replace(/\\/g, '\\\\');
fileContent += `downloadFolder=${seriesDownloadFolder}${lineEnding}`;
console.log(` -> DownloadFolder: ${seriesDownloadFolder}`);
} else {
const rawFilmFolder = CONFIG.JD_CREATE_SUBFOLDER ? `${CONFIG.PATHS_JD_FILMS}${safeTitle}` : `${CONFIG.PATHS_JD_FILMS}`;
const filmDownloadFolder = rawFilmFolder.replace(/\\/g, '\\\\');
fileContent += `downloadFolder=${filmDownloadFolder}${lineEnding}`;
console.log(` -> DownloadFolder: ${filmDownloadFolder}`);
}
console.log(` -> PackageName: ${safeTitle}`);
}
try {
await fs.promises.writeFile(filePath, fileContent);
await fs.promises.chmod(filePath, 0o666);
try {
await fs.promises.chown(filePath, 1000, 1000);
} catch (e) {
console.log("Note: Impossible de changer le propriétaire (chown).");
}
console.log(`✅ Fichier .crawljob (${fileName}) créé.`);
} catch (error: any) {
console.error(`❌ Erreur JDownloader (${fileName}):`, error.message);
}
}
+19
View File
@@ -0,0 +1,19 @@
const originalLog = console.log;
const originalError = console.error;
const originalWarn = console.warn;
function getTimestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 19);
}
console.log = (...args) => {
originalLog(`[${getTimestamp()}]`, ...args);
};
console.error = (...args) => {
originalError(`[${getTimestamp()}]`, ...args);
};
console.warn = (...args) => {
originalWarn(`[${getTimestamp()}]`, ...args);
};
+24
View File
@@ -0,0 +1,24 @@
import rateLimit from 'express-rate-limit';
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000, // Limite chaque IP à 200 requêtes par fenêtre
standardHeaders: true,
legacyHeaders: false,
message: {
error: "Trop de requêtes, veuillez réessayer plus tard."
}
});
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 tentatives échouées max par IP par fenêtre
skipSuccessfulRequests: true,
standardHeaders: true,
legacyHeaders: false,
message: {
error: "Trop de tentatives. Réessayez dans 15 minutes."
}
});
export default apiLimiter;
+35
View File
@@ -0,0 +1,35 @@
import fs from 'fs';
import path from 'path';
const SETTINGS_DIR = path.join(process.cwd(), 'database');
const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json');
export interface AppSettings {
activeSources: string[];
}
export function loadSettings(): AppSettings | null {
try {
if (!fs.existsSync(SETTINGS_FILE)) return null;
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
return JSON.parse(data);
} catch (error: any) {
console.error('[Settings] Erreur lors du chargement des paramètres:', error.message);
return null;
}
}
export function saveSettings(settings: Partial<AppSettings>) {
try {
if (!fs.existsSync(SETTINGS_DIR)) {
fs.mkdirSync(SETTINGS_DIR, { recursive: true });
}
const currentSettings = loadSettings() || { activeSources: [] };
const newSettings = { ...currentSettings, ...settings };
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(newSettings, null, 2), 'utf8');
} catch (error: any) {
console.error('[Settings] Erreur lors de la sauvegarde des paramètres:', error.message);
}
}
+180
View File
@@ -0,0 +1,180 @@
import { sourceRegistry } from '../core/registry.js';
import { ISource, SearchResult } from '../types/source.js';
import { loadSettings } from './settingsManager.js';
import { enrichSearchResults } from './tmdbEnricher.js';
import { CONFIG } from './config.js';
export interface GlobalState {
currentTitleId: string | null;
currentTitleName: string | null;
currentIdentifier: string | null; // hrefPath du contenu sélectionné (source-agnostic)
currentSelectionSource: string | null; // Nom de la source ayant fourni le contenu sélectionné
currentLiens: any[];
directUrlMap: Record<string, string>;
isSeries: boolean;
activeSources: string[]; // Liste des sources actives
isSiteOffline: boolean;
siteOfflineMessage: string;
isCheckingStatus: boolean;
trendingFilms: SearchResult[];
trendingSeries: SearchResult[];
recentItems: SearchResult[];
}
export const globalState: GlobalState = {
currentTitleId: null,
currentTitleName: null,
currentIdentifier: null,
currentSelectionSource: null,
currentLiens: [],
directUrlMap: {},
isSeries: false,
activeSources: [],
isSiteOffline: true,
siteOfflineMessage: "Vérification du statut du site en cours...",
isCheckingStatus: false,
trendingFilms: [],
trendingSeries: [],
recentItems: [],
};
/**
* Retourne les instances des sources actives via le registry.
*/
export function getActiveSources(): ISource[] {
return globalState.activeSources
.map(name => sourceRegistry.get(name))
.filter((source): source is ISource => source !== null);
}
/**
* Met à jour le statut du site (offline/online) et charge les tendances de toutes les sources actives.
*/
export async function checkSiteStatus() {
if (globalState.isCheckingStatus) return;
globalState.isCheckingStatus = true;
const sources = getActiveSources();
if (sources.length === 0) {
globalState.isSiteOffline = true;
globalState.siteOfflineMessage = "Aucune source configurée.";
globalState.trendingFilms = [];
globalState.trendingSeries = [];
globalState.recentItems = [];
globalState.isCheckingStatus = false;
return;
}
console.log(`[Vérification] Test de ${sources.length} sources actives...`);
let allFilms: SearchResult[] = [];
let allSeries: SearchResult[] = [];
let allRecent: SearchResult[] = [];
let onlineSourcesCount = 0;
try {
const results = await Promise.allSettled(sources.map(async (source) => {
const limit = CONFIG.MAX_RESULTS_PER_SOURCE;
const films = (await source.getTrending('movie')).slice(0, limit);
const series = (await source.getTrending('series')).slice(0, limit);
const recent = (source.getRecent ? await source.getRecent() : []).slice(0, limit);
return { source, films, series, recent };
}));
for (const result of results) {
if (result.status === 'fulfilled') {
const { source, films, series, recent } = result.value;
onlineSourcesCount++;
// Mettre en cache les tendances de cette source
sourceTrendsCache.set(source.name, { films: films || [], series: series || [], recent: recent || [] });
if (films && films.length > 0) allFilms = allFilms.concat(films);
if (series && series.length > 0) allSeries = allSeries.concat(series);
if (recent && recent.length > 0) allRecent = allRecent.concat(recent);
console.log(`[${source.name.toUpperCase()}] ${films?.length || 0} films, ${series?.length || 0} séries, ${recent?.length || 0} récents.`);
} else if (result.status === 'rejected') {
console.error(`[Erreur] Source indisponible: ${result.reason}`);
}
}
// Déduplication par titre pour éviter les doublons entre les sources
const deduplicate = (items: SearchResult[]) => {
const seen = new Set<string>();
const unique: SearchResult[] = [];
for (const item of items) {
if (!item.title) continue;
const key = item.title.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!seen.has(key)) {
seen.add(key);
unique.push(item);
}
}
return unique;
};
globalState.trendingFilms = await enrichSearchResults(deduplicate(allFilms));
globalState.trendingSeries = await enrichSearchResults(deduplicate(allSeries));
globalState.recentItems = await enrichSearchResults(deduplicate(allRecent));
if (onlineSourcesCount > 0) {
globalState.isSiteOffline = false;
globalState.siteOfflineMessage = "";
} else {
globalState.isSiteOffline = true;
globalState.siteOfflineMessage = "Toutes les sources actives sont indisponibles.";
}
} catch (error: any) {
console.error(`[ERREUR FATALE] ${error.message}`);
globalState.isSiteOffline = true;
globalState.siteOfflineMessage = "Erreur lors de la vérification des sources.";
} finally {
globalState.isCheckingStatus = false;
console.log("[Vérification] Terminée.");
}
}
/**
* Cache interne pour stocker les tendances de chaque source réussie.
*/
const sourceTrendsCache = new Map<string, { films: SearchResult[], series: SearchResult[], recent: SearchResult[] }>();
/**
* Reconstruit globalState.trendingFilms, trendingSeries et recentItems à partir du cache
* en respectant l'ordre de priorité défini dans globalState.activeSources.
*/
export async function rebuildTrendingFromCache() {
let allFilms: SearchResult[] = [];
let allSeries: SearchResult[] = [];
let allRecent: SearchResult[] = [];
const sources = getActiveSources();
for (const source of sources) {
const cached = sourceTrendsCache.get(source.name);
if (cached) {
if (cached.films && cached.films.length > 0) allFilms = allFilms.concat(cached.films);
if (cached.series && cached.series.length > 0) allSeries = allSeries.concat(cached.series);
if (cached.recent && cached.recent.length > 0) allRecent = allRecent.concat(cached.recent);
}
}
const deduplicate = (items: SearchResult[]) => {
const seen = new Set<string>();
const unique: SearchResult[] = [];
for (const item of items) {
if (!item.title) continue;
const key = item.title.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!seen.has(key)) {
seen.add(key);
unique.push(item);
}
}
return unique;
};
globalState.trendingFilms = await enrichSearchResults(deduplicate(allFilms));
globalState.trendingSeries = await enrichSearchResults(deduplicate(allSeries));
globalState.recentItems = await enrichSearchResults(deduplicate(allRecent));
console.log(`[Cache] Tendances reconstruites en mémoire pour ${sources.length} sources actives.`);
}
+129
View File
@@ -0,0 +1,129 @@
import { SearchResult } from '../types/source.js';
import { CONFIG } from './config.js';
// Cache (CleanTitle+Type => TmdbData)
interface TmdbData {
year: string | null;
title: string;
image: string | null;
}
const tmdbCache = new Map<string, TmdbData | null>();
const MAX_CACHE_SIZE = 500;
function enforceCacheLimit() {
if (tmdbCache.size > MAX_CACHE_SIZE) {
let i = 0;
for (const key of tmdbCache.keys()) {
tmdbCache.delete(key);
if (++i > 50) break; // Remove oldest 50
}
}
}
export function clearTmdbCache() {
tmdbCache.clear();
console.log("[TMDB] Cache réinitialisé.");
}
export function cleanTitle(title: string): string {
// Décode les entités HTML fréquentes
let clean = title.replace(/&#039;|&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
clean = clean.replace(/\b(saison|season)\s*\d+.*$/i, '');
clean = clean.replace(/\b(complete|french|truefrench|vostfr|multi|web-dl|1080p|720p|4k|x265|x264|bluray|bdrip)\b/gi, '');
clean = clean.replace(/,\s*le film\b/i, '');
clean = clean.replace(/[-_\[\]\(\):]/g, ' ');
clean = clean.replace(/\s{2,}/g, ' ');
return clean.trim();
}
async function fetchTmdbData(title: string, type: string): Promise<TmdbData | null> {
if (!CONFIG.TMDB_ENABLED || !CONFIG.TMDB_API_KEY) return null;
const clean = cleanTitle(title);
if (!clean) return null;
const searchType = (type === 'series' || type === 'serie' || type === 'anime') ? 'tv' : 'movie';
const cacheKey = `${searchType}:${clean.toLowerCase()}`;
if (tmdbCache.has(cacheKey)) {
return tmdbCache.get(cacheKey)!;
}
try {
console.log(`[TMDB] Recherche API: Type "${searchType}", Requête "${clean}" (Original: "${title}")`);
const url = `https://api.themoviedb.org/3/search/${searchType}?api_key=${CONFIG.TMDB_API_KEY}&query=${encodeURIComponent(clean)}&language=fr-FR&page=1`;
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
if (!res.ok) {
console.error(`[TMDB] Erreur HTTP ${res.status} pour "${clean}"`);
return null;
}
const data = await res.json();
let resultData: TmdbData | null = null;
if (data.results && data.results.length > 0) {
const first = data.results[0];
const dateStr = searchType === 'tv' ? first.first_air_date : first.release_date;
const year = (dateStr && typeof dateStr === 'string') ? dateStr.substring(0, 4) : null;
const tmdbTitle = searchType === 'tv' ? first.name : first.title;
const image = first.poster_path ? `https://image.tmdb.org/t/p/w300${first.poster_path}` : null;
resultData = { year, title: tmdbTitle, image };
console.log(`[TMDB] ✅ Trouvé "${clean}" -> ${tmdbTitle} (${year || 'N/A'})`);
} else {
console.log(`[TMDB] ❌ Aucun résultat pour "${clean}" en tant que ${searchType}.`);
// Fallback: search as the opposite type
const fallbackType = searchType === 'movie' ? 'tv' : 'movie';
console.log(`[TMDB] 🔄 Fallback: recherche "${clean}" en tant que ${fallbackType}...`);
const fallbackUrl = `https://api.themoviedb.org/3/search/${fallbackType}?api_key=${CONFIG.TMDB_API_KEY}&query=${encodeURIComponent(clean)}&language=fr-FR&page=1`;
const fbRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(3000) });
if (fbRes.ok) {
const fbData = await fbRes.json();
if (fbData.results && fbData.results.length > 0) {
const first = fbData.results[0];
const dateStr = fallbackType === 'tv' ? first.first_air_date : first.release_date;
const year = (dateStr && typeof dateStr === 'string') ? dateStr.substring(0, 4) : null;
const tmdbTitle = fallbackType === 'tv' ? first.name : first.title;
const image = first.poster_path ? `https://image.tmdb.org/t/p/w300${first.poster_path}` : null;
resultData = { year, title: tmdbTitle, image };
console.log(`[TMDB] ✅ Trouvé (Fallback) "${clean}" -> ${tmdbTitle} (${year || 'N/A'})`);
}
}
}
enforceCacheLimit();
tmdbCache.set(cacheKey, resultData);
return resultData;
} catch (e) {
console.error(`[TMDB] ❌ Erreur recherche pour "${clean}":`, (e as Error).message);
return null;
}
}
export async function enrichSearchResults(results: SearchResult[]): Promise<SearchResult[]> {
if (!CONFIG.TMDB_ENABLED || !CONFIG.TMDB_API_KEY) return results;
const enrichmentPromises = results.map(async (r) => {
// We now enrich ALWAYS, not just when !r.year, so ZT benefits from it.
const tmdbData = await fetchTmdbData(r.title, r.type || 'movie');
if (tmdbData) {
if (tmdbData.year) r.year = tmdbData.year;
if (tmdbData.image) r.image = tmdbData.image;
// Clean up original title by removing the HTML entities if TMDB didn't return a title
r.title = tmdbData.title || cleanTitle(r.title);
} else {
// Still decode HTML entities if TMDB fails
r.title = cleanTitle(r.title);
}
return r;
});
await Promise.allSettled(enrichmentPromises);
return results;
}
+297
View File
@@ -0,0 +1,297 @@
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
// ============================================================
// User Store — Fichier JSON avec hashing scrypt
// ============================================================
const USERS_DIR = path.join(process.cwd(), 'database');
const USERS_FILE = path.join(USERS_DIR, 'users.json');
// Scrypt parameters (NIST SP 800-132 / OWASP recommendations)
const SCRYPT_KEYLEN = 64;
const SCRYPT_SALT_LEN = 32;
const SCRYPT_OPTIONS: crypto.ScryptOptions = {
N: 16384, // CPU/memory cost (2^14)
r: 8, // Block size
p: 1, // Parallelism
};
// Input validation constraints
const USERNAME_MIN = 3;
const USERNAME_MAX = 32;
const USERNAME_REGEX = /^[a-zA-Z0-9_.-]+$/;
const PASSWORD_MIN = 8;
const PASSWORD_MAX = 128;
// Password generation charset
const PASSWORD_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&*_+-=';
export interface User {
id: string;
username: string;
passwordHash: string; // format: "salt_hex:hash_hex"
role: 'admin' | 'user';
createdAt: string; // ISO 8601
passwordChangedAt: string; // ISO 8601 — pour invalidation session post-reset
mustChangePassword?: boolean;
preferences?: Record<string, any>;
}
// Données publiques (jamais le hash)
export interface PublicUser {
id: string;
username: string;
role: 'admin' | 'user';
createdAt: string;
mustChangePassword?: boolean;
preferences?: Record<string, any>;
}
// ========================= INTERNAL HELPERS =========================
function loadUsers(): User[] {
try {
if (!fs.existsSync(USERS_FILE)) return [];
const data = fs.readFileSync(USERS_FILE, 'utf8');
return JSON.parse(data);
} catch (error: any) {
console.error('[UserStore] Erreur lecture users.json:', error.message);
return [];
}
}
function saveUsers(users: User[]): void {
try {
if (!fs.existsSync(USERS_DIR)) {
fs.mkdirSync(USERS_DIR, { recursive: true });
}
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2), 'utf8');
} catch (error: any) {
console.error('[UserStore] Erreur écriture users.json:', error.message);
throw new Error('Impossible de sauvegarder les utilisateurs.');
}
}
function hashPassword(password: string, salt: Buffer): string {
const hash = crypto.scryptSync(password, salt, SCRYPT_KEYLEN, SCRYPT_OPTIONS);
return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
function verifyPasswordHash(password: string, stored: string): boolean {
const [saltHex, hashHex] = stored.split(':');
if (!saltHex || !hashHex) return false;
const salt = Buffer.from(saltHex, 'hex');
const storedHash = Buffer.from(hashHex, 'hex');
const computedHash = crypto.scryptSync(password, salt, SCRYPT_KEYLEN, SCRYPT_OPTIONS);
// Vérification en temps constant — anti timing attack
if (storedHash.length !== computedHash.length) return false;
return crypto.timingSafeEqual(storedHash, computedHash);
}
/** Génère un mot de passe aléatoire cryptographiquement sûr et conforme aux exigences */
function generatePassword(length: number = 16): string {
while (true) {
const bytes = crypto.randomBytes(length);
let password = '';
for (let i = 0; i < length; i++) {
password += PASSWORD_CHARS[bytes[i] % PASSWORD_CHARS.length];
}
if (validatePassword(password) === null) {
return password;
}
}
}
/** Exécute un scrypt bidon pour uniformiser le temps de réponse (anti user-enumeration) */
export function dummyScrypt(): void {
const fakeSalt = crypto.randomBytes(SCRYPT_SALT_LEN);
crypto.scryptSync('dummy-password-for-timing', fakeSalt, SCRYPT_KEYLEN, SCRYPT_OPTIONS);
}
// ========================= VALIDATION =========================
function validateUsername(username: string): string | null {
if (!username || typeof username !== 'string') return 'Nom d\'utilisateur requis.';
const trimmed = username.trim();
if (trimmed.length < USERNAME_MIN) return `Nom d'utilisateur trop court (min ${USERNAME_MIN} caractères).`;
if (trimmed.length > USERNAME_MAX) return `Nom d'utilisateur trop long (max ${USERNAME_MAX} caractères).`;
if (!USERNAME_REGEX.test(trimmed)) return 'Nom d\'utilisateur invalide (lettres, chiffres, _ . - uniquement).';
return null;
}
export function validatePassword(password: string): string | null {
if (!password || typeof password !== 'string') return 'Mot de passe requis.';
if (password.length < PASSWORD_MIN) return `Le mot de passe doit faire au moins ${PASSWORD_MIN} caractères.`;
if (password.length > PASSWORD_MAX) return `Le mot de passe est trop long (max ${PASSWORD_MAX} caractères).`;
if (!/[A-Z]/.test(password)) return "Le mot de passe doit contenir au moins une lettre majuscule.";
if (!/[0-9]/.test(password)) return "Le mot de passe doit contenir au moins un chiffre.";
if (!/[^a-zA-Z0-9]/.test(password)) return "Le mot de passe doit contenir au moins un caractère spécial (ex: !, @, #, $, %...).";
return null;
}
// ========================= PUBLIC API =========================
/** Vérifie si au moins un utilisateur existe */
export function hasAnyUser(): boolean {
return loadUsers().length > 0;
}
/** Retourne tous les utilisateurs (sans les hash de mots de passe) */
export function getAllUsers(): PublicUser[] {
return loadUsers().map(({ id, username, role, createdAt, mustChangePassword, preferences }) => ({
id, username, role, createdAt, mustChangePassword, preferences
}));
}
/** Retourne un user par ID (données internes, avec hash) */
export function getUserById(id: string): User | null {
return loadUsers().find(u => u.id === id) || null;
}
/** Retourne un user par username (case-insensitive) */
export function getUserByUsername(username: string): User | null {
const lower = username.toLowerCase().trim();
return loadUsers().find(u => u.username.toLowerCase() === lower) || null;
}
/** Crée un nouvel utilisateur */
export function createUser(username: string, password: string, role: 'admin' | 'user', mustChangePassword = false): { user: PublicUser; clearPassword: string } {
// Validation
const usernameError = validateUsername(username);
if (usernameError) throw new Error(usernameError);
const passwordError = validatePassword(password);
if (passwordError) throw new Error(passwordError);
const users = loadUsers();
// Unicité du username (case-insensitive)
if (users.some(u => u.username.toLowerCase() === username.toLowerCase().trim())) {
throw new Error('Ce nom d\'utilisateur est déjà pris.');
}
const salt = crypto.randomBytes(SCRYPT_SALT_LEN);
const now = new Date().toISOString();
const newUser: User = {
id: crypto.randomUUID(),
username: username.trim(),
passwordHash: hashPassword(password, salt),
role,
createdAt: now,
passwordChangedAt: now,
mustChangePassword,
preferences: {},
};
users.push(newUser);
saveUsers(users);
console.log(`[UserStore] Utilisateur créé: ${newUser.username} (${newUser.role}), mustChangePassword: ${mustChangePassword}`);
return {
user: { id: newUser.id, username: newUser.username, role: newUser.role, createdAt: newUser.createdAt, mustChangePassword: newUser.mustChangePassword, preferences: newUser.preferences },
clearPassword: password,
};
}
/** Crée un utilisateur avec un mot de passe auto-généré */
export function createUserWithGeneratedPassword(username: string, role: 'admin' | 'user'): { user: PublicUser; clearPassword: string } {
const password = generatePassword(16);
return createUser(username, password, role, true);
}
/** Vérifie les identifiants et retourne le user (ou null) */
export function verifyCredentials(username: string, password: string): User | null {
const user = getUserByUsername(username);
if (!user) {
// Anti user-enumeration : on fait un scrypt bidon pour uniformiser le temps de réponse
dummyScrypt();
return null;
}
if (verifyPasswordHash(password, user.passwordHash)) {
return user;
}
return null;
}
/** Supprime un utilisateur par ID */
export function deleteUser(id: string): boolean {
const users = loadUsers();
const userToDelete = users.find(u => u.id === id);
if (!userToDelete) throw new Error('Utilisateur introuvable.');
// Anti-lockout : interdire la suppression du dernier admin
if (userToDelete.role === 'admin') {
const adminCount = users.filter(u => u.role === 'admin').length;
if (adminCount <= 1) {
throw new Error('Impossible de supprimer le dernier administrateur.');
}
}
const filtered = users.filter(u => u.id !== id);
saveUsers(filtered);
console.log(`[UserStore] Utilisateur supprimé: ${userToDelete.username}`);
return true;
}
/** Reset le mot de passe d'un utilisateur et active mustChangePassword */
export function resetPassword(id: string): { clearPassword: string } {
const users = loadUsers();
const user = users.find(u => u.id === id);
if (!user) throw new Error('Utilisateur introuvable.');
const newPassword = generatePassword(16);
const salt = crypto.randomBytes(SCRYPT_SALT_LEN);
user.passwordHash = hashPassword(newPassword, salt);
user.passwordChangedAt = new Date().toISOString();
user.mustChangePassword = true;
saveUsers(users);
console.log(`[UserStore] Mot de passe réinitialisé pour: ${user.username}`);
return { clearPassword: newPassword };
}
/** Change le mot de passe d'un utilisateur après vérification de la complexité */
export function changeUserPassword(id: string, newPassword: string): void {
const passwordError = validatePassword(newPassword);
if (passwordError) throw new Error(passwordError);
const users = loadUsers();
const user = users.find(u => u.id === id);
if (!user) throw new Error('Utilisateur introuvable.');
const salt = crypto.randomBytes(SCRYPT_SALT_LEN);
user.passwordHash = hashPassword(newPassword, salt);
user.passwordChangedAt = new Date().toISOString();
user.mustChangePassword = false;
saveUsers(users);
console.log(`[UserStore] Mot de passe modifié avec succès pour: ${user.username}`);
}
/** Met à jour les préférences d'un utilisateur */
export function updateUserPreferences(id: string, updates: Record<string, any>): void {
const users = loadUsers();
const user = users.find(u => u.id === id);
if (!user) throw new Error('Utilisateur introuvable.');
user.preferences = { ...user.preferences, ...updates };
saveUsers(users);
console.log(`[UserStore] Préférences mises à jour pour: ${user.username}`);
}
+12
View File
@@ -0,0 +1,12 @@
import { FlixArtAuth } from './plugins/flixart/auth.js';
import { CONFIG } from './src/utils/config.js';
(async () => {
try {
console.log('Testing FlixArt Auth...');
const cookie = await FlixArtAuth.getCookie(true);
console.log('Cookie:', cookie);
} catch (e) {
console.error(e);
}
})();
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"resolveJsonModule": true,
"allowJs": true
},
"exclude": ["node_modules", "dist", "public", "views"]
}
+18
View File
@@ -0,0 +1,18 @@
<%- include('partials/header') %>
<%- include('partials/sidebar') %>
<main class="content-wrapper">
<section id="section-downloads" class="section">
<div class="header-row">
<h2>Téléchargements</h2>
<button id="btn-refresh-downloads" class="btn-icon-only">
<i data-lucide="refresh-cw"></i>
</button>
</div>
<div id="downloads-list" class="downloads-container">
<div class="empty-state">Aucun téléchargement actif</div>
</div>
</section>
</main>
<%- include('partials/footer') %>
+89
View File
@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Agora — Connexion</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a1a1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Agora">
<link rel="apple-touch-icon" href="/images/icone-192.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
<script src="/lucide.min.js"></script>
<script src="/app.auth.js"></script>
<style>
.error-msg {
color: var(--primary);
font-size: 0.95rem;
margin-top: 15px;
font-weight: 600;
}
</style>
<link rel="icon" type="image/svg+xml" href="/logo_svg.svg">
</head>
<body>
<div id="login-overlay" class="overlay active">
<div class="login-box">
<div class="logo-large">
<img src="/images/logo_svg.svg" alt="Logo" class="icon-xl">
<h1>Agora</h1>
</div>
<form id="login-form">
<input type="text" id="login-username" placeholder="Nom d'utilisateur" required autofocus autocomplete="username">
<div class="password-container">
<input type="password" id="login-password" placeholder="Mot de passe" required autocomplete="current-password">
<button type="button" class="toggle-password" tabindex="-1">
<i data-lucide="eye" class="eye-icon"></i>
</button>
</div>
<button type="submit">Connexion</button>
</form>
<div id="login-error" class="error-msg hidden"></div>
</div>
</div>
<script>
if (typeof lucide !== 'undefined') lucide.createIcons();
if (window.AuthHelpers) AuthHelpers.initPasswordToggles();
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
const errorDiv = document.getElementById('login-error');
errorDiv.classList.add('hidden');
errorDiv.textContent = '';
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (data.success) {
// Redirection vers la page par défaut
const defaultPage = document.cookie.split('; ').find(c => c.startsWith('defaultPage='));
const target = defaultPage ? defaultPage.split('=')[1] : '/trending';
window.location.href = target;
} else {
errorDiv.textContent = data.error || 'Identifiants invalides.';
errorDiv.classList.remove('hidden');
}
} catch (err) {
errorDiv.textContent = 'Erreur de connexion au serveur.';
errorDiv.classList.remove('hidden');
}
});
</script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<%- include('partials/header') %>
<%- include('partials/sidebar') %>
<main class="content-wrapper">
<section id="section-manual" class="section">
<div class="hero-header">
<h2>Ajout Manuel JDownloader</h2>
</div>
<div class="form-card" style="max-width: 600px; margin: 0 auto;">
<h3 style="margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem;">Ajouter des liens manuellement</h3>
<div style="margin-bottom: 1.25rem;">
<label for="manual-title" style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: white;">Nom du Film ou de la Série</label>
<input type="text" id="manual-title" placeholder="Ex: Inception, Breaking Bad S01..." style="width: 100%; padding: 12px; background: var(--bg-main); border: 1px solid var(--border); color: white; border-radius: var(--radius);">
</div>
<div style="margin-bottom: 1.25rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: white;">Type de contenu</label>
<div class="manual-type-selector">
<div class="type-option active" data-type="film">
<i data-lucide="film" style="width: 18px; height: 18px;"></i>
Film
</div>
<div class="type-option" data-type="series">
<i data-lucide="tv" style="width: 18px; height: 18px;"></i>
Série
</div>
</div>
<input type="hidden" id="manual-type" value="film">
</div>
<div style="margin-bottom: 1.5rem;">
<label for="manual-links" style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: white;">Liens (un lien par ligne)</label>
<textarea id="manual-links" placeholder="Copiez-collez vos liens ici (1fichier, turbobit, etc.)&#10;Un lien par ligne..." style="width: 100%; height: 180px; padding: 12px; background: var(--bg-main); border: 1px solid var(--border); color: white; border-radius: var(--radius); font-family: monospace; line-height: 1.5; resize: vertical;"></textarea>
</div>
<button class="btn-primary" id="btn-manual-submit" style="width: 100%; display: flex; justify-content: center; align-items: center; gap: 8px; padding: 12px 24px; font-weight: bold;">
<i data-lucide="plus-circle"></i> Envoyer à JDownloader
</button>
</div>
</section>
</main>
<%- include('partials/footer') %>
+26
View File
@@ -0,0 +1,26 @@
</div>
<div id="modal-overlay" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Titre</h3>
<button id="modal-close"><i data-lucide="x"></i></button>
</div>
<div id="modal-body" class="modal-body"></div>
</div>
</div>
<div id="toast" class="toast hidden">Notification</div>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW enregistré!', reg.scope))
.catch(err => console.log('SW échec:', err));
});
}
</script>
<script src="/app.auth.js"></script>
<script src="/app.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Agora</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a1a1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Agora">
<link rel="apple-touch-icon" href="/images/icone-192.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
<script src="/lucide.min.js"></script>
<link rel="icon" type="image/svg+xml" href="/logo_svg.svg">
</head>
<body>
<div id="app-container">
+93
View File
@@ -0,0 +1,93 @@
<nav class="sidebar" id="sidebar">
<script>
(function() {
if (localStorage.getItem('sidebar-collapsed') === 'true') {
document.getElementById('sidebar').classList.add('collapsed');
}
})();
</script>
<div class="brand">
<div class="brand-logo">
<img src="/images/logo_svg.svg" alt="Logo" class="brand-icon">
<span>Agora</span>
</div>
<button id="btn-sidebar-toggle" class="btn-sidebar-toggle" title="Réduire la barre latérale">
<i data-lucide="chevron-left" id="sidebar-toggle-icon"></i>
</button>
</div>
<ul class="nav-links">
<a href="/trending" style="text-decoration: none; color: inherit;">
<li class="<%= page === 'trending' ? 'active' : '' %>" data-target="section-trending">
<i data-lucide="flame"></i>
<span>Tendances</span>
</li>
</a>
<a href="/recent" style="text-decoration: none; color: inherit;">
<li class="<%= page === 'recent' ? 'active' : '' %>" data-target="section-recent">
<i data-lucide="clock"></i>
<span>Ajouts récents</span>
</li>
</a>
<a href="/search" style="text-decoration: none; color: inherit;">
<li class="<%= page === 'search' ? 'active' : '' %>" data-target="section-search">
<i data-lucide="search"></i>
<span>Recherche</span>
</li>
</a>
<a href="/downloads" style="text-decoration: none; color: inherit;">
<li class="<%= page === 'downloads' ? 'active' : '' %>" data-target="section-downloads">
<i data-lucide="download"></i>
<span>Téléchargements</span>
</li>
</a>
<a href="/manual" style="text-decoration: none; color: inherit;">
<li class="<%= page === 'manual' ? 'active' : '' %>" data-target="section-manual">
<i data-lucide="plus-circle"></i>
<span>Ajout manuel</span>
</li>
</a>
<% if (typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin') { %>
<a href="/settings" style="text-decoration: none; color: inherit;">
<li class="<%= page === 'settings' ? 'active' : '' %>" data-target="section-settings">
<i data-lucide="settings"></i>
<span>Paramètres</span>
</li>
</a>
<% } %>
</ul>
<div class="nav-footer">
<button id="btn-logout" class="btn-text">
<i data-lucide="log-out"></i>
<span>Déconnexion</span>
</button>
</div>
</nav>
<script>
document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.getElementById('sidebar');
const toggleBtn = document.getElementById('btn-sidebar-toggle');
const toggleIcon = document.getElementById('sidebar-toggle-icon');
if (!sidebar || !toggleBtn || !toggleIcon) return;
const updateIcon = (isCollapsed) => {
toggleIcon.setAttribute('data-lucide', isCollapsed ? 'chevron-right' : 'chevron-left');
if (window.lucide) {
window.lucide.createIcons({ root: toggleBtn });
}
};
// Sync initial icon state
const isCollapsed = sidebar.classList.contains('collapsed');
updateIcon(isCollapsed);
toggleBtn.addEventListener('click', () => {
const willCollapse = !sidebar.classList.contains('collapsed');
sidebar.classList.toggle('collapsed', willCollapse);
localStorage.setItem('sidebar-collapsed', willCollapse ? 'true' : 'false');
updateIcon(willCollapse);
});
});
</script>
+34
View File
@@ -0,0 +1,34 @@
<%- include('partials/header') %>
<%- include('partials/sidebar') %>
<main class="content-wrapper">
<section id="section-recent" class="section">
<div class="hero-header">
<h2>Récemment Ajoutés</h2>
<p>Les derniers ajouts disponibles sur la source</p>
</div>
<div class="filter-pills" style="margin-bottom: 2rem;">
<label class="pill">
<input type="radio" name="recent-type" value="all" checked>
<span>Tout</span>
</label>
<label class="pill">
<input type="radio" name="recent-type" value="film">
<span>Films</span>
</label>
<label class="pill">
<input type="radio" name="recent-type" value="serie">
<span>Séries</span>
</label>
</div>
<div id="recent-grid" class="media-grid">
<div class="loader-wrapper">
<div class="loader"></div>
</div>
</div>
</section>
</main>
<%- include('partials/footer') %>
+55
View File
@@ -0,0 +1,55 @@
<%- include('partials/header') %>
<%- include('partials/sidebar') %>
<main class="content-wrapper">
<section id="section-search" class="section">
<div class="hero-header">
<h2>Recherche Globale</h2>
</div>
<div class="search-hero">
<div class="search-input-wrapper">
<i data-lucide="search" class="search-icon-inside"></i>
<input type="text" id="search-input" placeholder="Titre du film, série...">
<button id="btn-search-trigger" class="btn-search-action">Go</button>
</div>
<p style="font-size: 0.8rem; color: var(--text-sec); margin-top: 8px; text-align: center;">
⚠️ <i>Recherche stricte (Seulement pour la LocalDB) : Saisissez exactement le titre du film/série recherché.</i>
</p>
<div class="filter-pills" style="margin-top: 15px;" id="search-filters-container">
<label class="pill">
<input type="radio" name="search-type" value="film" checked>
<span>Films</span>
</label>
<label class="pill">
<input type="radio" name="search-type" value="serie">
<span>Séries</span>
</label>
<label class="pill localdb-filter hidden">
<input type="radio" name="search-type" value="game">
<span>Jeux</span>
</label>
<label class="pill localdb-filter hidden">
<input type="radio" name="search-type" value="software">
<span>Logiciels</span>
</label>
<label class="pill localdb-filter hidden">
<input type="radio" name="search-type" value="book">
<span>Livres/BD</span>
</label>
<label class="pill localdb-filter hidden">
<input type="radio" name="search-type" value="music">
<span>Musique</span>
</label>
<label class="pill localdb-filter hidden">
<input type="radio" name="search-type" value="other">
<span>Autres</span>
</label>
</div>
</div>
<div id="search-results" class="media-grid"></div>
</section>
</main>
<%- include('partials/footer') %>
+410
View File
@@ -0,0 +1,410 @@
<%- include('partials/header') %>
<script src="/sortable.min.js"></script>
<%- include('partials/sidebar') %>
<main class="content-wrapper">
<section id="section-settings" class="section">
<div class="hero-header">
<h2>Paramètres</h2>
</div>
<% if (typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin') { %>
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; justify-content: space-between;">
<span style="display: flex; align-items: center; gap: 8px;"><i data-lucide="refresh-cw"></i> Système & Mises à jour</span>
<span style="font-size: 0.75rem; background: rgba(255,255,255,0.1); padding: 4px 8px; border-radius: 4px; font-family: monospace;">v<%= appVersion %></span>
</h3>
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
Vérifiez si une nouvelle version d'Agora est disponible et consultez les instructions de mise à jour.
</p>
<button id="check-update-btn" class="btn-action" style="padding: 10px 20px; display: flex; align-items: center; justify-content: center; gap: 8px;">
<i data-lucide="download-cloud" style="width: 16px; height: 16px;"></i> Vérifier les mises à jour
</button>
<div id="update-results-container" style="display: none; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px dashed var(--border);">
<!-- Injecté par JS -->
</div>
</div>
<% } %>
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
<i data-lucide="database"></i> Sources de Recherche
</h3>
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
Sélectionnez la source principale (prioritaire) et les sources secondaires à utiliser.
</p>
<div style="margin-bottom: 1.5rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
<i data-lucide="list-ordered" style="color: var(--primary); width: 18px; height: 18px;"></i> Priorité & Activation des Sources
</label>
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1.2rem;">
Activez les sources et glissez-déposez pour définir leur ordre de priorité. La première source active (cochée) sera la source principale.
</p>
<div id="sources-sortable-container" style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 1rem;">
<!-- Les sources ordonnables seront injectées ici par JS -->
</div>
</div>
</div>
<% if (typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin') { %>
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
<i data-lucide="server"></i> Hébergeurs Préférés
</h3>
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
Glissez et déposez les hébergeurs pour définir leur ordre de préférence. Les liens de téléchargement seront triés selon cet ordre.
</p>
<div id="hosters-sortable-container" style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 1rem;">
<% if (typeof preferredHosters !== 'undefined' && preferredHosters.length > 0) { %>
<% preferredHosters.forEach(function(hoster) {
const h = hoster.toLowerCase();
let domain = h + '.com';
if (h === 'turbobit') domain = 'turbobit.net';
if (h === 'rapidgator') domain = 'rapidgator.net';
if (h === 'mega') domain = 'mega.nz';
if (h === 'gofile') domain = 'gofile.io';
if (h === 'nitroflare') domain = 'nitroflare.com';
%>
<div class="sortable-item" data-hoster="<%= hoster %>" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--bg-main); border: 1px solid var(--border); border-radius: 8px; cursor: grab;">
<div style="display: flex; align-items: center; gap: 12px;">
<i data-lucide="grip-vertical" style="color: var(--text-sec); width: 18px; height: 18px;"></i>
<img src="https://www.google.com/s2/favicons?domain=<%= domain %>&sz=32" alt="logo" style="width: 20px; height: 20px; border-radius: 4px;" onerror="this.style.display='none'; this.nextElementSibling.style.display='inline-block';">
<i data-lucide="server" style="color: var(--text-sec); width: 18px; height: 18px; display: none;"></i>
<span style="font-weight: 500; text-transform: capitalize;"><%= hoster %></span>
</div>
</div>
<% }); %>
<% } %>
</div>
<div style="display: flex; gap: 10px;">
<button id="save-hosters-btn" class="btn-action" style="padding: 10px 20px; display: flex; align-items: center; justify-content: center; gap: 8px; flex: 1;">
<i data-lucide="save" style="width: 16px; height: 16px;"></i> Enregistrer l'ordre
</button>
</div>
</div>
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
<i data-lucide="plug"></i> Configuration des Plugins
</h3>
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
Modifiez les adresses et clés d'accès des plugins. Les modifications seront appliquées immédiatement sans redémarrage.
</p>
<form id="plugins-config-form">
<% if (typeof pluginsToConfigure !== 'undefined' && pluginsToConfigure.length > 0) { %>
<% pluginsToConfigure.forEach(function(plugin) { %>
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 1rem;">
<strong style="display: flex; align-items: center; gap: 6px; margin-bottom: 0.8rem; color: var(--text-main); font-size: 0.95rem; text-transform: capitalize;">
<i data-lucide="box" style="width: 14px; height: 14px; color: var(--text-sec);"></i>
<%= plugin.name %>
</strong>
<%
const urlField = plugin.fields.find(f => f.key.endsWith('_URL'));
const urlEmpty = urlField && !urlField.default;
%>
<% if (plugin.name.includes('TMDB')) { %>
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 12px; margin-top: -8px;">
<i data-lucide="info" style="width: 14px; height: 14px; vertical-align: middle;"></i>
Pour obtenir une clé d'API, créez un compte gratuit sur <a href="https://www.themoviedb.org/settings/api" target="_blank" style="color: var(--primary);">themoviedb.org</a>, générez une clé (API v3 Auth) et collez-la ci-dessous.
</p>
<% plugin.fields.forEach(function(field) { %>
<div style="margin-bottom: 0.8rem;">
<label for="config_<%= field.key %>" style="font-size: 0.8rem; color: var(--text-sec); margin-bottom: 4px; display: block;"><%= field.label %></label>
<% if (field.key.endsWith('_ENABLED') || field.key === 'JD_FORCED_START') { %>
<select id="config_<%= field.key %>" name="<%= field.key %>" style="width: 100%; padding: 10px; background: rgba(0,0,0,0.2); border: 1px solid var(--border); border-radius: 6px; color: var(--text-main); font-size: 0.9rem;">
<option value="true" <%= field.default === 'true' ? 'selected' : '' %>>Activé</option>
<option value="false" <%= field.default === 'false' ? 'selected' : '' %>>Désactivé</option>
</select>
<% } else { %>
<input type="text" id="config_<%= field.key %>" name="<%= field.key %>"
placeholder="<%= field.placeholder %>" value="<%= field.default || '' %>"
style="width: 100%; padding: 10px; background: rgba(0,0,0,0.2); border: 1px solid var(--border); border-radius: 6px; color: var(--text-main); font-size: 0.9rem;">
<% } %>
</div>
<% }); %>
<% } else { %>
<% plugin.fields.forEach(function(field) { %>
<div style="margin-bottom: 0.8rem;">
<label for="config_<%= field.key %>" style="font-size: 0.8rem; color: var(--text-sec); margin-bottom: 4px; display: block;"><%= field.label %></label>
<input type="text" id="config_<%= field.key %>" name="<%= field.key %>"
placeholder="<%= field.placeholder %>" value="<%= field.default || '' %>"
data-is-url="<%= field.key.endsWith('_URL') ? 'true' : 'false' %>"
data-plugin="<%= plugin.name %>"
style="padding: 8px; font-size: 0.9rem; width: 100%; border: 1px solid var(--border); background: var(--bg-main); color: var(--text-main); border-radius: 6px;">
</div>
<% }); %>
<% } %>
</div>
<% }); %>
<div style="display: flex; gap: 10px;">
<button type="submit" class="btn-action" style="padding: 10px 20px; display: flex; align-items: center; justify-content: center; gap: 8px; flex: 1;">
<i data-lucide="save" style="width: 16px; height: 16px;"></i> Enregistrer & Recharger
</button>
</div>
<% } else { %>
<p style="font-size: 0.85rem; color: var(--text-sec);">Aucun plugin détecté.</p>
<% } %>
</form>
</div>
<% } %>
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem;">
Page d'accueil par défaut</h3>
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
Sélectionnez la page sur laquelle vous souhaitez être redirigé par défaut à l'ouverture de
l'application.
</p>
<select id="select-default-page" style="margin-top: 0.5rem; margin-bottom: 0;">
<option value="/trending">Tendances</option>
<option value="/recent">Ajouts récents</option>
<option value="/search">Recherche</option>
<option value="/downloads">Téléchargements</option>
<option value="/manual">Ajout manuel</option>
<option value="/settings">Paramètres</option>
</select>
</div>
<div class="form-card" style="max-width: 750px; margin: 0 auto;">
<label style="display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<span style="font-weight: 600;">Intégration JDownloader</span>
<input type="checkbox" id="toggle-jd" style="width: 20px; height: 20px;">
</label>
<p style="font-size: 0.85rem; color: var(--text-sec); margin-top: 10px;">Si désactivé, le lien
1fichier sera affiché directement sous forme de pop-up sans être envoyé à JDownloader.</p>
</div>
<% if (typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin') { %>
<div class="form-card" style="max-width: 750px; margin: 1.5rem auto 0 auto;">
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
<i data-lucide="users" style="width: 20px; height: 20px;"></i> Gestion des Utilisateurs
</h3>
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
Gérez les comptes utilisateurs. Seuls les administrateurs ont accès à cette section.
</p>
<!-- Liste des utilisateurs -->
<div id="admin-users-list" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 1.5rem;">
<!-- Injecté par JS -->
</div>
<!-- Formulaire d'ajout -->
<div style="border-top: 1px solid var(--border); padding-top: 1rem;">
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
<i data-lucide="user-plus" style="color: var(--primary); width: 16px; height: 16px;"></i> Ajouter un utilisateur
</label>
<div style="display: flex; gap: 10px; margin-bottom: 12px; align-items: center;">
<input type="text" id="admin-new-username" placeholder="Nom d'utilisateur"
style="flex: 1; margin: 0; padding: 12px 16px;"
minlength="3" maxlength="32" pattern="[a-zA-Z0-9_.\-]+">
<select id="admin-new-role" style="width: auto; min-width: 130px; margin: 0; padding: 12px 16px;">
<option value="user">Utilisateur</option>
<option value="admin">Admin</option>
</select>
</div>
<button id="admin-add-user-btn" class="btn-action" style="width: 100%; padding: 12px 20px;">
Créer l'utilisateur
</button>
</div>
</div>
<% } %>
<!-- Modal pour afficher le mot de passe généré -->
<div id="admin-password-modal" class="modal-overlay hidden">
<div class="modal-content" style="max-width: 420px;">
<h3 style="margin-bottom: 1rem; display: flex; align-items: center; gap: 8px;">
<i data-lucide="key" style="width: 20px; height: 20px; color: var(--primary);"></i>
Mot de passe généré
</h3>
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
Copiez ce mot de passe et transmettez-le à l'utilisateur. Il ne sera plus affiché.
</p>
<div id="admin-generated-password" style="background: var(--bg-main); border: 1px solid var(--border); border-radius: 8px; padding: 14px; font-family: monospace; font-size: 1.1rem; text-align: center; color: var(--primary); font-weight: 700; letter-spacing: 1px; user-select: all; cursor: text; word-break: break-all;"></div>
<button id="admin-copy-password-btn" style="width: 100%; margin-top: 1rem; padding: 10px; border-radius: 8px; background: var(--bg-card); border: 1px solid var(--border); color: white; font-weight: 600; cursor: pointer;">
Copier le mot de passe
</button>
<button id="admin-close-modal-btn" style="width: 100%; margin-top: 0.5rem; padding: 10px; border-radius: 8px; background: var(--primary); border: none; color: white; font-weight: 600; cursor: pointer;">
Fermer
</button>
</div>
</div>
<div style="text-align: center; margin-top: 3rem; margin-bottom: 1rem;">
<p style="font-size: 0.85rem; color: var(--text-sec); display: inline-flex; align-items: center; gap: 6px; background: var(--bg-card); padding: 6px 12px; border-radius: 20px; border: 1px solid var(--border);">
<i data-lucide="info" style="width: 14px; height: 14px;"></i>
Agora v<%= appVersion %>
</p>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// --- Plugins Config Form ---
const form = document.getElementById('plugins-config-form');
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = form.querySelector('button[type="submit"]');
const origHtml = btn.innerHTML;
btn.innerHTML = '<i data-lucide="loader-2" class="spin" style="width: 16px; height: 16px;"></i> Enregistrement...';
btn.disabled = true;
if (window.lucide) lucide.createIcons();
const fd = new FormData(form);
const config = Object.fromEntries(fd.entries());
try {
const res = await fetch('/api/admin/plugins/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config })
});
const data = await res.json();
if (data.success) {
window.showModal(
'Succès',
`<div style="padding: 1rem; text-align: center; color: var(--text-main);">
<i data-lucide="check-circle" style="color: #10b981; width: 48px; height: 48px; margin-bottom: 1rem;"></i>
<p>Configuration enregistrée avec succès.</p>
<p style="font-size: 0.9rem; color: var(--text-sec); margin-top: 0.5rem;">Plugins actifs : ${data.activeSources.join(', ')}</p>
</div>`
);
if (window.lucide) lucide.createIcons();
setTimeout(() => window.location.reload(), 2000);
} else {
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>${data.error}</p></div>`);
}
} catch (err) {
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>Erreur lors de la sauvegarde.</p></div>`);
console.error(err);
} finally {
btn.innerHTML = origHtml;
btn.disabled = false;
if (window.lucide) lucide.createIcons();
}
});
}
// --- Hosters Sorting ---
const hostersContainer = document.getElementById('hosters-sortable-container');
if (hostersContainer) {
Sortable.create(hostersContainer, {
animation: 150,
handle: '.sortable-item', // Permet de draguer sur tout l'item
ghostClass: 'dragging-hoster', // Classe appliquée pendant le drag
});
document.getElementById('save-hosters-btn')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
const origHtml = btn.innerHTML;
btn.innerHTML = '<i data-lucide="loader-2" class="spin" style="width: 16px; height: 16px;"></i> Enregistrement...';
btn.disabled = true;
if (window.lucide) lucide.createIcons();
const order = Array.from(hostersContainer.querySelectorAll('.sortable-item')).map(el => el.dataset.hoster);
try {
const res = await fetch('/api/admin/hosters/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preferredHosters: order })
});
const data = await res.json();
if (data.success) {
window.showModal(
'Succès',
`<div style="padding: 1rem; text-align: center; color: var(--text-main);">
<i data-lucide="check-circle" style="color: #10b981; width: 48px; height: 48px; margin-bottom: 1rem;"></i>
<p>Ordre des hébergeurs enregistré avec succès !</p>
</div>`
);
if (window.lucide) lucide.createIcons();
} else {
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>${data.error}</p></div>`);
}
} catch (err) {
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>Erreur réseau lors de la sauvegarde.</p></div>`);
} finally {
btn.innerHTML = origHtml;
btn.disabled = false;
if (window.lucide) lucide.createIcons();
}
});
}
// --- Updates Checking ---
const checkUpdateBtn = document.getElementById('check-update-btn');
if (checkUpdateBtn) {
checkUpdateBtn.addEventListener('click', async () => {
const btn = checkUpdateBtn;
const origHtml = btn.innerHTML;
btn.innerHTML = '<i data-lucide="loader-2" class="spin" style="width: 16px; height: 16px;"></i> Vérification...';
btn.disabled = true;
if (window.lucide) lucide.createIcons();
const container = document.getElementById('update-results-container');
container.style.display = 'none';
container.innerHTML = '';
try {
const res = await fetch('/api/admin/check-update');
const data = await res.json();
if (data.success) {
container.style.display = 'block';
const isNew = data.currentVersion !== data.latestVersion;
let html = '';
if (isNew) {
html += `<div style="background: rgba(16, 185, 129, 0.1); border: 1px solid #10b981; padding: 12px; border-radius: 6px; margin-bottom: 1rem; color: #10b981; font-weight: 600; display: flex; align-items: center; gap: 8px;">
<i data-lucide="party-popper" style="width: 20px; height: 20px;"></i> Nouvelle version disponible : ${data.latestVersion} !
</div>`;
} else {
html += `<div style="background: rgba(255, 255, 255, 0.05); border: 1px solid var(--border); padding: 12px; border-radius: 6px; margin-bottom: 1rem; color: var(--text-main); font-weight: 500; display: flex; align-items: center; gap: 8px;">
<i data-lucide="check-circle" style="color: #10b981; width: 20px; height: 20px;"></i> Votre système est à jour (v${data.currentVersion}).
</div>`;
}
if (data.notes && data.notes.length > 0) {
html += `<h4 style="margin-bottom: 8px; font-size: 0.9rem;">Nouveautés :</h4><ul style="margin: 0 0 1rem 0; padding-left: 20px; color: var(--text-sec); font-size: 0.85rem; line-height: 1.5;">`;
data.notes.forEach(n => html += `<li>${n}</li>`);
html += `</ul>`;
}
html += `<h4 style="margin-bottom: 8px; font-size: 0.9rem;">Comment mettre à jour :</h4>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div style="background: var(--bg-main); padding: 12px; border-radius: 6px; border: 1px solid var(--border);">
<strong style="display: block; font-size: 0.8rem; color: var(--primary); margin-bottom: 4px;">Via Docker</strong>
<code style="font-size: 0.85rem; color: var(--text-main); user-select: all; cursor: text;">${data.updateInfo.dockerCmd}</code>
</div>
<div style="background: var(--bg-main); padding: 12px; border-radius: 6px; border: 1px solid var(--border);">
<strong style="display: block; font-size: 0.8rem; color: var(--primary); margin-bottom: 4px;">Installation Manuelle (.zip)</strong>
<a href="${data.updateInfo.zipUrl}" target="_blank" style="font-size: 0.85rem; color: var(--text-main); text-decoration: underline;">Télécharger l'archive depuis le site</a>
${data.updateInfo.zipSha256 ? `<div style="margin-top: 8px; font-size: 0.8rem; color: var(--text-muted);">Somme de contrôle (SHA-256) :<br><code style="font-size: 0.75rem; user-select: all; background: rgba(0,0,0,0.2); padding: 2px 4px; border-radius: 4px; word-break: break-all;">${data.updateInfo.zipSha256}</code></div>` : ''}
</div>
</div>`;
container.innerHTML = html;
} else {
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>${data.error}</p></div>`);
}
} catch (err) {
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>Erreur réseau lors de la vérification.</p></div>`);
} finally {
btn.innerHTML = origHtml;
btn.disabled = false;
if (window.lucide) lucide.createIcons();
}
});
}
});
</script>
</section>
</main>
<%- include('partials/footer') %>
+282
View File
@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Agora — Installation</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a1a1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Agora">
<link rel="apple-touch-icon" href="/images/icone-192.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
<script src="/lucide.min.js"></script>
<script src="/app.auth.js"></script>
<style>
.setup-container {
min-height: 100vh;
width: 100%;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 2rem 1rem;
background: var(--bg-main);
overflow-y: auto;
}
.setup-box {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 480px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
margin: auto;
}
.setup-logo {
text-align: center;
margin-bottom: 2rem;
}
.setup-logo img {
width: 80px;
height: 80px;
margin-bottom: 1rem;
}
.setup-logo h1 {
font-size: 1.8rem;
font-weight: 700;
color: white;
margin: 0;
}
.setup-logo p {
color: var(--text-sec);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.setup-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(229, 9, 20, 0.15);
color: var(--primary);
font-size: 0.75rem;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
margin-bottom: 1.5rem;
border: 1px solid rgba(229, 9, 20, 0.3);
}
.setup-form { display: flex; flex-direction: column; gap: 1rem; }
.setup-form label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-sec);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.setup-form input {
width: 100%;
padding: 12px 16px;
background: var(--bg-main);
border: 1px solid var(--border);
border-radius: 10px;
color: white;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.setup-form input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(229, 9, 20, 0.15);
}
.setup-form button[type="submit"] {
width: 100%;
padding: 14px;
background: var(--primary);
color: white;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
margin-top: 0.5rem;
}
.setup-form button[type="submit"]:hover { background: #c40812; }
.setup-form button[type="submit"]:active { transform: scale(0.98); }
.setup-form button[type="submit"]:disabled {
background: #4b5563;
color: #9ca3af;
cursor: not-allowed;
transform: none;
}
.setup-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: 10px 14px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
}
</style>
<link rel="icon" type="image/svg+xml" href="/logo_svg.svg">
</head>
<body>
<div class="setup-container">
<div class="setup-box">
<div class="setup-logo">
<img src="/images/logo_svg.svg" alt="Logo">
<h1>Agora</h1>
<p>Configuration initiale</p>
</div>
<div style="text-align: center;">
<span class="setup-badge">
<i data-lucide="shield" style="width: 14px; height: 14px;"></i>
Création du compte administrateur
</span>
</div>
<% if (error) { %>
<div class="setup-error" style="margin-bottom: 1rem;">
<%= error %>
</div>
<% } %>
<div id="setup-client-error" class="setup-error" style="margin-bottom: 1rem; display: none;"></div>
<form class="setup-form" id="setup-form" action="/setup" method="POST">
<div>
<label for="setup-username">Nom d'utilisateur</label>
<input type="text" id="setup-username" name="username" placeholder="admin" required
minlength="3" maxlength="32" pattern="[a-zA-Z0-9_.\-]+" autofocus autocomplete="username">
</div>
<div class="password-requirements" style="text-align: left;">
<strong>Exigences du mot de passe :</strong>
<ul style="margin-top: 8px;">
<li id="req-length" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins 8 caractères</li>
<li id="req-upper" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins une majuscule (A-Z)</li>
<li id="req-number" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins un chiffre (0-9)</li>
<li id="req-special" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins un caractère spécial</li>
<li id="req-match" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Mots de passe identiques</li>
</ul>
</div>
<div>
<label for="setup-password">Mot de passe</label>
<div class="password-container">
<input type="password" id="setup-password" name="password" placeholder="Faut sécuriser le compte !"
required minlength="8" maxlength="128" autocomplete="new-password">
<button type="button" class="toggle-password" tabindex="-1">
<i data-lucide="eye" class="eye-icon"></i>
</button>
</div>
</div>
<div>
<label for="setup-confirm">Confirmer le mot de passe</label>
<div class="password-container">
<input type="password" id="setup-confirm" name="confirmPassword" placeholder="Retapez le mot de passe"
required minlength="8" maxlength="128" autocomplete="new-password">
<button type="button" class="toggle-password" tabindex="-1">
<i data-lucide="eye" class="eye-icon"></i>
</button>
</div>
</div>
<% if (typeof pluginsToConfigure !== 'undefined' && pluginsToConfigure.length > 0) { %>
<div style="margin: 2rem 0; border-top: 1px solid var(--border); padding-top: 1.5rem; text-align: left;">
<h3 style="margin-bottom: 1rem; color: var(--text-main); font-size: 1.1rem; display: flex; align-items: center; gap: 8px;">
<i data-lucide="plug" style="width: 18px; height: 18px; color: var(--primary);"></i>
Configuration des Plugins (Optionnel)
</h3>
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1.5rem; line-height: 1.4;">
Des sources ont été détectées. Vous pouvez les configurer maintenant ou plus tard dans les paramètres.
</p>
<% pluginsToConfigure.forEach(function(plugin) { %>
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 1rem;">
<strong style="display: flex; align-items: center; gap: 6px; margin-bottom: 0.8rem; color: var(--text-main); font-size: 0.95rem; text-transform: capitalize;">
<i data-lucide="box" style="width: 14px; height: 14px; color: var(--text-sec);"></i>
<%= plugin.name %>
</strong>
<% plugin.fields.forEach(function(field) { %>
<div style="margin-bottom: 0.8rem;">
<label for="config_<%= field.key %>" style="font-size: 0.8rem; color: var(--text-sec); margin-bottom: 4px; display: block;"><%= field.label %></label>
<input type="text" id="config_<%= field.key %>" name="config[<%= field.key %>]"
placeholder="<%= field.placeholder %>" value="<%= field.default || '' %>"
style="padding: 8px; font-size: 0.9rem; width: 100%; border: 1px solid var(--border); background: var(--bg-main); color: var(--text-main); border-radius: 6px;">
</div>
<% }); %>
</div>
<% }); %>
</div>
<% } %>
<button type="submit" id="setup-submit-btn" disabled>Créer le compte administrateur</button>
<div style="text-align: center; margin-top: 3rem; margin-bottom: 1rem;">
<p style="font-size: 0.85rem; color: var(--text-sec); display: inline-flex; align-items: center; gap: 6px; background: var(--bg-card); padding: 6px 12px; border-radius: 20px; border: 1px solid var(--border);">
<i data-lucide="info" style="width: 14px; height: 14px;"></i>
Agora v<%= appVersion %>
</p>
</div>
</form>
</div>
</div>
<script>
if (typeof lucide !== 'undefined') lucide.createIcons();
// Initialize password visibility toggles using the shared library
if (window.AuthHelpers) {
AuthHelpers.initPasswordToggles();
}
const passwordInput = document.getElementById('setup-password');
const confirmInput = document.getElementById('setup-confirm');
const submitBtn = document.getElementById('setup-submit-btn');
const validateSetupInputs = () => {
if (!window.AuthHelpers) return;
const val = passwordInput.value;
const confirmVal = confirmInput.value;
// Use the shared library validation logic
const statuses = AuthHelpers.validateComplexity(val, confirmVal);
// Use the shared library UI updater
AuthHelpers.updateRequirementsUI(document.getElementById('setup-form'), statuses);
submitBtn.disabled = !statuses.allValid;
};
passwordInput.addEventListener('input', validateSetupInputs);
confirmInput.addEventListener('input', validateSetupInputs);
document.getElementById('setup-form').addEventListener('submit', function(e) {
const password = passwordInput.value;
const confirm = confirmInput.value;
const errorDiv = document.getElementById('setup-client-error');
errorDiv.style.display = 'none';
if (password !== confirm) {
e.preventDefault();
errorDiv.textContent = 'Les mots de passe ne correspondent pas.';
errorDiv.style.display = 'block';
return;
}
});
</script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<%- include('partials/header') %>
<%- include('partials/sidebar') %>
<main class="content-wrapper">
<section id="section-trending" class="section">
<div class="hero-header">
<h2>🔥 Tendances du Moment</h2>
<p>Les contenus les plus populaires en ce moment</p>
</div>
<div class="filter-pills" style="margin-bottom: 2rem;">
<label class="pill">
<input type="radio" name="trending-type" value="film" checked>
<span>Films</span>
</label>
<label class="pill">
<input type="radio" name="trending-type" value="serie">
<span>Séries</span>
</label>
</div>
<div id="trending-grid" class="media-grid">
<div class="loader-wrapper">
<div class="loader"></div>
</div>
</div>
</section>
</main>
<%- include('partials/footer') %>