commit b8d3dd52ecb768cb992139087826c509e41b855d Author: Nolhan Date: Tue Sep 15 21:45:47 2026 +0200 Initial commit (v1.5.9) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4e6f02e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +database +sessions +images +downloads +.git +.github +*.db +*.sqlite +.env +.dockerignore diff --git a/.env.exemple b/.env.exemple new file mode 100644 index 0000000..e3ad67e --- /dev/null +++ b/.env.exemple @@ -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 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..084ae3c --- /dev/null +++ b/.github/FUNDING.yml @@ -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'] diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..90017b9 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75b18cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +.env +sessions/ +.DS_Store +*.crawljob +/downloads/ +dist/ +database/darkiworld.db +database/settings.json +database/users.json +scripts/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7591fe2 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..60e891f --- /dev/null +++ b/Makefile @@ -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)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a9fc63 --- /dev/null +++ b/README.md @@ -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; + search(query: string, mediaType?: MediaType): Promise; + getTrending(mediaType: MediaType): Promise; + getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise; + resolveLink?(linkId: string): Promise; // 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). diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/database/config.json b/database/config.json new file mode 100644 index 0000000..28c3662 --- /dev/null +++ b/database/config.json @@ -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" +} \ No newline at end of file diff --git a/database/flixart_cookie.txt b/database/flixart_cookie.txt new file mode 100644 index 0000000..6548449 --- /dev/null +++ b/database/flixart_cookie.txt @@ -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 \ No newline at end of file diff --git a/database/fs24_cookie.txt b/database/fs24_cookie.txt new file mode 100644 index 0000000..a4baedf --- /dev/null +++ b/database/fs24_cookie.txt @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0f7fd1c --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/images/login_web.webp b/images/login_web.webp new file mode 100644 index 0000000..2406a37 Binary files /dev/null and b/images/login_web.webp differ diff --git a/images/screenshot_quality.png b/images/screenshot_quality.png new file mode 100644 index 0000000..13d0f46 Binary files /dev/null and b/images/screenshot_quality.png differ diff --git a/images/screenshot_tendances.png b/images/screenshot_tendances.png new file mode 100644 index 0000000..4de59c4 Binary files /dev/null and b/images/screenshot_tendances.png differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..91abd29 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1812 @@ +{ + "name": "Agora", + "version": "1.5.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "Agora", + "version": "1.5.1", + "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" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-GbypG0bog68UbOq2tSAp7SclvCUm3ha1uDi58OPRGK1NfRvCIu7Gz0M7fTGtpNG1T9a29GpuurQj9zEcT/lMXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.1.tgz", + "integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/session-file-store": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@types/session-file-store/-/session-file-store-1.2.6.tgz", + "integrity": "sha512-5SqSrqUr6/Ah0g46202WoFE3Fd9P5gLUW34b8bitA0qffOanUzbArVDOx1bvchUK56yZCzhHNREXK7e56lsQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/express-session": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/bagpipe": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz", + "integrity": "sha512-42sAlmPDKes1nLm/aly+0VdaopSU9br+jkRELedhQxI5uXHgtk47I83Mpmf4zoNTRMASdLFtUkimlu/Z9zQ8+g==", + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejs": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", + "integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kruptein": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/kruptein/-/kruptein-2.2.3.tgz", + "integrity": "sha512-BTwprBPTzkFT9oTugxKd3WnWrX630MqUDsnmBuoa98eQs12oD4n4TeI0GbpdGcYn/73Xueg2rfnw+oK4dovnJg==", + "license": "MIT", + "dependencies": { + "asn1.js": "^5.4.1" + }, + "engines": { + "node": ">6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/session-file-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/session-file-store/-/session-file-store-1.5.0.tgz", + "integrity": "sha512-60IZaJNzyu2tIeHutkYE8RiXVx3KRvacOxfLr2Mj92SIsRIroDsH0IlUUR6fJAjoTW4RQISbaOApa2IZpIwFdQ==", + "license": "Apache-2.0", + "dependencies": { + "bagpipe": "^0.3.5", + "fs-extra": "^8.0.1", + "kruptein": "^2.0.4", + "object-assign": "^4.1.1", + "retry": "^0.12.0", + "write-file-atomic": "3.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f9a726a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/plugins/ZT/api.ts b/plugins/ZT/api.ts new file mode 100644 index 0000000..addb76e --- /dev/null +++ b/plugins/ZT/api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); +} + + diff --git a/plugins/ZT/index.ts b/plugins/ZT/index.ts new file mode 100644 index 0000000..14d2571 --- /dev/null +++ b/plugins/ZT/index.ts @@ -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(); + 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 { + if (!this.baseUrl) { + console.warn('[ZT] ⚠️ ZT_URL non définie.'); + return false; + } + return true; + } + + async search(query: string, mediaType: MediaType = 'movie'): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()); diff --git a/plugins/ZT/parser.ts b/plugins/ZT/parser.ts new file mode 100644 index 0000000..03d3e53 --- /dev/null +++ b/plugins/ZT/parser.ts @@ -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 = /
]*>([\s\S]*?)(?=
]*>\s*]*>\s*([^<]+)/); + if (!titleMatch) continue; + + const href = titleMatch[1]!.trim(); + const title = titleMatch[2]!.trim(); + + const imgMatch = block.match(/]*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>/g; + let releaseMatch: RegExpExecArray | null; + while ((releaseMatch = releaseRegex.exec(html)) !== null) { + releaseNames.push(releaseMatch[1]!.trim()); + } + + const sections = html.split(/]*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(/(?:|)?(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(); + const seenSubs = new Set(); + 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]*?)(?:|]*class="postinfo")/gi; + let sSectionMatch: RegExpExecArray | null; + while ((sSectionMatch = sectionRegex.exec(html)) !== null) { + const type = sSectionMatch[1].toLowerCase(); + const seasonBlock = sSectionMatch[2]!; + const seasonRegex = /]*href="([^"]+)"[^>]*>([\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(/]*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; +} + diff --git a/plugins/flixart/api.ts b/plugins/flixart/api.ts new file mode 100644 index 0000000..98413c8 --- /dev/null +++ b/plugins/flixart/api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + // 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 }; + } + + +} diff --git a/plugins/flixart/auth.ts b/plugins/flixart/auth.ts new file mode 100644 index 0000000..05bb238 --- /dev/null +++ b/plugins/flixart/auth.ts @@ -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 { + 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; + } + } +} diff --git a/plugins/flixart/dump.ts b/plugins/flixart/dump.ts new file mode 100644 index 0000000..37f96ad --- /dev/null +++ b/plugins/flixart/dump.ts @@ -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(); diff --git a/plugins/flixart/index.ts b/plugins/flixart/index.ts new file mode 100644 index 0000000..c475ce9 --- /dev/null +++ b/plugins/flixart/index.ts @@ -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 { + return FlixArtAPI.search(query, mediaType); + } + + async getTrending(mediaType: MediaType): Promise { + const type = mediaType === 'series' ? 'series' : 'movie'; + return FlixArtAPI.getTrending(type); + } + + async getRecent(): Promise { + return this.getTrending('movie'); + } + + async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise { + return FlixArtAPI.getSelection(identifier); + } + + async getContentLinks(identifier: string, season?: number): Promise { + return FlixArtAPI.getContentLinks(identifier, season); + } + + async healthCheck(): Promise { + 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 { + 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()); diff --git a/plugins/flixart/parser.ts b/plugins/flixart/parser.ts new file mode 100644 index 0000000..2f8d04b --- /dev/null +++ b/plugins/flixart/parser.ts @@ -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(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +export class FlixArtParser { + static parseSearchAjax(htmlStr: string): SearchResult[] { + const results: SearchResult[] = []; + const cardRegex = /]*>([\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>/); + if (titleMatch) title = unescapeHtml(titleMatch[1].trim()); + + let year = null; + const yearMatch = inner.match(/([^<]+)<\/span>/); + if (yearMatch) year = yearMatch[1].trim(); + + let image = null; + const imgMatch = inner.match(/]+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 = `
\\s*
\\s*

(${sectionTitle}|${fallbackTitle})<\\/h2>[\\s\\S]*?<\\/section>`; + let sectionMatch = htmlStr.match(new RegExp(sectionRegexStr, 'i')); + + if (!sectionMatch) return results; + const sectionHtml = sectionMatch[0]; + + const cardRegex = /
([\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(/

]*>([^<]+)<\/a><\/h3>/); + if (titleMatch) { + href = unescapeHtml(titleMatch[1]); + title = unescapeHtml(titleMatch[2].trim()); + } + + let image = null; + const imgMatch = inner.match(/]+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>/); + if (numMatch) { + seasons.push({ label: `Saison ${numMatch[1].trim()}`, value: val }); + } + } + + const rowRegex = /
]*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(/]+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 }; + } +} diff --git a/plugins/freetelecharger/api.ts b/plugins/freetelecharger/api.ts new file mode 100644 index 0000000..57936a1 --- /dev/null +++ b/plugins/freetelecharger/api.ts @@ -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 { + 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 { + return ftGet(`${baseUrl}/1/recherche1/1.html?rech_fiche=${encodeURIComponent(query)}`); +} + +export async function fetchTrending(baseUrl: string): Promise { + return ftGet(`${baseUrl}/page/1.html`); +} + +export async function fetchPage(pageUrl: string): Promise { + return ftGet(pageUrl); +} diff --git a/plugins/freetelecharger/index.ts b/plugins/freetelecharger/index.ts new file mode 100644 index 0000000..2e58860 --- /dev/null +++ b/plugins/freetelecharger/index.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()); diff --git a/plugins/freetelecharger/parser.ts b/plugins/freetelecharger/parser.ts new file mode 100644 index 0000000..2bfa35e --- /dev/null +++ b/plugins/freetelecharger/parser.ts @@ -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*
\s*([^<\n]+?)\s*
\s*([^<\n]+?)\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 = //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(items: T[]): T[] { + const seen = new Set(); + 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 :
+ * puis
+ */ +export function parseSearchResults(html: string, baseUrl: string): SearchResult[] { + const results: SearchResult[] = []; + const blockRegex = /\s*]+src="([^"]+)"[^>]*>[\s\S]*?\s*]*>([\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) : Titre + */ +export function parseTrendingResults(html: string, baseUrl: string): SearchResult[] { + const results: SearchResult[] = []; + const blockRegex = /]*data-tip-b64="[^"]+"[^>]*>\s*]+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 : dans la section #link, précédé d'un

HOST

+ * - Série : (à résoudre via resolveLink) + */ +export function parseContentHTML(html: string, isSeries: boolean): ContentLinks { + const links: VideoLink[] = []; + + if (isSeries) { + const episodeRegex = /]+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

, URL dans ) + const meta = parseFilmMetadata(html); + const sectionMatch = html.match(/]*>\s*([A-Za-z0-9-]+)\s*<\/p>[\s\S]{0,800}?]+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 : avec contenant [HOST] et . + */ +export function parseEpisodeLinks(html: string): { host: string; url: string }[] { + const out: { host: string; url: string }[] = []; + const tableMatch = html.match(/]*class="gridtable"[\s\S]*?<\/table>/i); + if (!tableMatch) return out; + const rows = tableMatch[0].match(//gi) || []; + for (const row of rows) { + const hostMatch = row.match(/\[([^\]]+)\]/); + const aMatch = row.match(/]*href\s*=\s*["']?([^"'\s>]+)/i); + if (hostMatch && aMatch) { + out.push({ + host: hostMatch[1]!.toLowerCase().trim(), + url: aMatch[1]!.trim(), + }); + } + } + return out; +} diff --git a/plugins/fs24/api.ts b/plugins/fs24/api.ts new file mode 100644 index 0000000..ca631de --- /dev/null +++ b/plugins/fs24/api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/plugins/fs24/auth.ts b/plugins/fs24/auth.ts new file mode 100644 index 0000000..2c99ff9 --- /dev/null +++ b/plugins/fs24/auth.ts @@ -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 { + // 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; + } + } +} diff --git a/plugins/fs24/index.ts b/plugins/fs24/index.ts new file mode 100644 index 0000000..6211660 --- /dev/null +++ b/plugins/fs24/index.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const content = await this.getContentLinks(identifier); + + return { + links: content.links, + seasons: [], + isSeries: type === 'series' + }; + } +} + +// ── Auto-registration ── +sourceRegistry.register(new FS24Source()); diff --git a/plugins/fs24/parser.ts b/plugins/fs24/parser.ts new file mode 100644 index 0000000..3bc5373 --- /dev/null +++ b/plugins/fs24/parser.ts @@ -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 = /
]*onclick="location\.href='([^']+)'"[^>]*>([\s\S]*?)(?=
]*src=['"]([^'"]+)['"]/); + const image = imgMatch ? imgMatch[1]! : null; + + const titleMatch = block.match(/
([^<]+)<\/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 = /
([\s\S]*?)<\/div>\s*|
([\s\S]*?)(?=
]*src=['"]([^'"]+)['"]/); + const image = imgMatch ? imgMatch[1]! : null; + + // Extract title + const titleMatch = block.match(/
([^<]+)<\/div>/); + if (!titleMatch) continue; + let titleRaw = titleMatch[1]!.trim(); + + // Extract link + const linkMatch = block.match(/]*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:|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:||| + 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; +} diff --git a/plugins/hydracker/api.ts b/plugins/hydracker/api.ts new file mode 100644 index 0000000..ed8b2eb --- /dev/null +++ b/plugins/hydracker/api.ts @@ -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 { + 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 = {}) { + 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; +} diff --git a/plugins/hydracker/index.ts b/plugins/hydracker/index.ts new file mode 100644 index 0000000..834596a --- /dev/null +++ b/plugins/hydracker/index.ts @@ -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 { + console.warn('[Hydracker] ⚠️ Plugin désactivé (Site fermé définitivement). Conservé pour archivage.'); + return false; + } + + async search(query: string, mediaType: MediaType = 'movie'): Promise { + 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 { + // 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 { + 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 { + // 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 { + 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 { + // 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 | null = null; + + async checkPremiumStatus(): Promise { + 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 { + // 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 { + 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()); diff --git a/plugins/hydracker/parser.ts b/plugins/hydracker/parser.ts new file mode 100644 index 0000000..9474942 --- /dev/null +++ b/plugins/hydracker/parser.ts @@ -0,0 +1,167 @@ +import { SearchResult, MediaType, VideoLink } from '../../src/types/source.js'; + +export const QUALITY_MAP: Record = { + 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 = { + 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 = { + 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; +} diff --git a/plugins/loadix/index.ts b/plugins/loadix/index.ts new file mode 100644 index 0000000..62afc7a --- /dev/null +++ b/plugins/loadix/index.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + 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 { + 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 { + 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 { + 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()); diff --git a/plugins/localdb/index.ts b/plugins/localdb/index.ts new file mode 100644 index 0000000..6542f3b --- /dev/null +++ b/plugins/localdb/index.ts @@ -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 | null = null; // exact token -> row indices + private titleByNorm: Map | null = null; // full norm -> row indices (Tier 1) + private prefixIndex: Map | 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 { + 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(rows.length); + const tokenIndex = new Map(); + const titleByNorm = new Map(); + const prefixIndex = new Map(); + + const push = (m: Map, 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(); + 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 { + 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(); + 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 { + // 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 { + 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 { + 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()); diff --git a/plugins/movix/api.ts b/plugins/movix/api.ts new file mode 100644 index 0000000..8332b46 --- /dev/null +++ b/plugins/movix/api.ts @@ -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 { + 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 { + 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 { + 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(); + } +} diff --git a/plugins/movix/index.ts b/plugins/movix/index.ts new file mode 100644 index 0000000..f5c455c --- /dev/null +++ b/plugins/movix/index.ts @@ -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 { + 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 { + 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 { + 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 { + // Fallback on movies trending as recent if no specific endpoint + return this.getTrending('movie'); + } + + public async getContentLinks(identifier: string, season?: number): Promise { + 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 { + const content = await this.getContentLinks(identifier); + return { + links: content.links, + seasons: [], + isSeries: type === 'series' + }; + } + + public async resolveLink(combinedId: string): Promise { + 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()); diff --git a/plugins/ztnews/api.ts b/plugins/ztnews/api.ts new file mode 100644 index 0000000..76f2cef --- /dev/null +++ b/plugins/ztnews/api.ts @@ -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 { + 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 { + 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 { + return ztnGet(`${baseUrl}/?p=${type}`); +} + +export async function fetchPage(pageUrl: string): Promise { + return ztnGet(pageUrl); +} diff --git a/plugins/ztnews/index.ts b/plugins/ztnews/index.ts new file mode 100644 index 0000000..98c6735 --- /dev/null +++ b/plugins/ztnews/index.ts @@ -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(); + 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 { + if (!this.baseUrl) { + console.warn('[ztnews] ⚠️ ZTTEAM_URL non définie.'); + return false; + } + return true; + } + + async search(query: string, mediaType: MediaType = 'movie'): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + console.log(`[ztTeam] 🔗 Renvoi du lien dl-protect brut (résolution via navigateur ou JDownloader requise) : ${linkId}`); + return linkId || null; + } +} + +sourceRegistry.register(new ZtTeamAPI()); diff --git a/plugins/ztnews/parser.ts b/plugins/ztnews/parser.ts new file mode 100644 index 0000000..e6b0d60 --- /dev/null +++ b/plugins/ztnews/parser.ts @@ -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(items: T[]): T[] { + const seen = new Set(); + 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 = /
]*>([\s\S]*?)(?=
]*>\s*]*>\s*([^<]+)/); + if (!titleMatch) continue; + const href = absUrl(titleMatch[1]!.trim(), baseUrl); + const title = titleMatch[2]!.trim(); + const imgMatch = block.match(/]*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