v1.5.9 (Actuel)
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
database
|
||||||
|
sessions
|
||||||
|
images
|
||||||
|
downloads
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
.env
|
||||||
|
.dockerignore
|
||||||
+23
-6
@@ -1,5 +1,5 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Hydr'Hacked — Configuration
|
# Agora — Configuration
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# --- Plugin : Zone-Téléchargement (Source par défaut) ---
|
# --- Plugin : Zone-Téléchargement (Source par défaut) ---
|
||||||
@@ -13,10 +13,26 @@ ZT_URL= #https://...
|
|||||||
# --- Plugin : Base de Données Locale SQLite (Optionnel) ---
|
# --- Plugin : Base de Données Locale SQLite (Optionnel) ---
|
||||||
# DB_PATH=./database/darkiworld.db
|
# DB_PATH=./database/darkiworld.db
|
||||||
|
|
||||||
# --- Plugin : Hydracker ---
|
|
||||||
# HYDRACKER_URL= #https://
|
# --- Enrichissement TMDB (Optionnel) ---
|
||||||
# HYDRACKER_API_KEY= # 15746...
|
TMDB_ENABLED=false
|
||||||
# HYDRACKER_TIMEOUT=30000 # Timeout de healthcheck et d'appels API en millisecondes (30s par défaut)
|
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 ---
|
# --- Configuration Application ---
|
||||||
PORT=3067
|
PORT=3067
|
||||||
@@ -26,7 +42,7 @@ SECRET=generer-une-cle-aleatoire-ici
|
|||||||
# Si définis, le compte admin est créé automatiquement au premier lancement.
|
# Si définis, le compte admin est créé automatiquement au premier lancement.
|
||||||
# Si absents, accédez à /setup pour créer le premier admin manuellement.
|
# Si absents, accédez à /setup pour créer le premier admin manuellement.
|
||||||
# ADMIN_USERNAME=admin
|
# ADMIN_USERNAME=admin
|
||||||
# ADMIN_PASSWORD=hydracked
|
# ADMIN_PASSWORD=agora
|
||||||
|
|
||||||
# --- Paramètres de scan ---
|
# --- Paramètres de scan ---
|
||||||
MIN_MINUTES=15
|
MIN_MINUTES=15
|
||||||
@@ -43,3 +59,4 @@ MAX_MINUTES=30
|
|||||||
|
|
||||||
# JD_CREATE_SUBFOLDER=true
|
# JD_CREATE_SUBFOLDER=true
|
||||||
# JD_AUTOSTART=true
|
# JD_AUTOSTART=true
|
||||||
|
# JD_FORCED_START=false
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ database/darkiworld.db
|
|||||||
database/settings.json
|
database/settings.json
|
||||||
database/users.json
|
database/users.json
|
||||||
scripts/
|
scripts/
|
||||||
|
BACKUP HYDRACKED_AGORA/
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
variables:
|
|
||||||
# Enable Docker BuildKit for multi-arch builds
|
|
||||||
DOCKER_BUILDKIT: 1
|
|
||||||
IMAGE_NAME: $CI_REGISTRY_IMAGE
|
|
||||||
|
|
||||||
stages:
|
|
||||||
- build
|
|
||||||
|
|
||||||
build-and-push:
|
|
||||||
stage: build
|
|
||||||
image: docker:24.0.5
|
|
||||||
services:
|
|
||||||
- docker:24.0.5-dind
|
|
||||||
before_script:
|
|
||||||
# Log into the GitLab Container Registry using provided CI/CD variables
|
|
||||||
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u "$CI_REGISTRY_USER" --password-stdin
|
|
||||||
|
|
||||||
# Set up QEMU for multi-architecture builds (equivalent to setup-qemu-action)
|
|
||||||
- docker run --privileged --rm tonistiigi/binfmt --install all
|
|
||||||
|
|
||||||
# Create and boot a new builder instance (equivalent to setup-buildx-action)
|
|
||||||
- docker buildx create --use --name multi-arch-builder
|
|
||||||
- docker buildx inspect --bootstrap
|
|
||||||
script:
|
|
||||||
# Determine the tags based on the trigger event (Release tag vs Manual branch run)
|
|
||||||
- |
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
# If triggered by a tag (release), build with the specific version and 'latest'
|
|
||||||
TAG_ARGS="-t $IMAGE_NAME:$CI_COMMIT_TAG -t $IMAGE_NAME:latest"
|
|
||||||
else
|
|
||||||
# If triggered manually on a branch, use the branch name as the tag
|
|
||||||
TAG_ARGS="-t $IMAGE_NAME:$CI_COMMIT_REF_SLUG"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build and push the Docker image for both amd64 and arm64 architectures
|
|
||||||
- docker buildx build --push --platform linux/amd64,linux/arm64 $TAG_ARGS .
|
|
||||||
rules:
|
|
||||||
# Trigger automatically when pushing to main branch
|
|
||||||
- if: $CI_COMMIT_BRANCH == "main"
|
|
||||||
# Trigger automatically when a new tag is pushed
|
|
||||||
- if: $CI_COMMIT_TAG
|
|
||||||
# Allow manual triggering from the GitLab Web UI
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "web"
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# 🐍 Hydr'Hacked
|
# 🐍 Agora
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> Merci de bien lire tout ça avant de déployer le server
|
> 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
|
> Si vous êtes débutant(e) cette vidéo devrait répondre à vos questions
|
||||||
[Vidéo tutoriel + DB](https://gofile.io/d/3CA4rk)
|
[Vidéo tutoriel + DB](https://gofile.io/d/3CA4rk)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
> "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." 💅
|
> "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." 💅
|
||||||
|
|
||||||
@@ -12,16 +12,36 @@
|
|||||||
|
|
||||||
## 🚀 Présentation
|
## 🚀 Présentation
|
||||||
|
|
||||||
**Hydr'Hacked** est une solution complète (Serveur API + Interface Web) pour crawler, rechercher et télécharger du contenu depuis plusieurs sources :
|
**Agora** est une solution complète (Serveur API + Interface Web) pour crawler, rechercher et télécharger du contenu depuis plusieurs sources :
|
||||||
- 🆓 **ZT** : Source principale, 100% gratuite et sans token (films et séries).
|
- 🆓 **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 (ZT News) pour des exclusivités et nouveaux ajouts.
|
- 📰 **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.
|
- ⚡ **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.).
|
- 🗄️ **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).
|
- 🛡️ **Hydracker** : Source premium secondaire (nécessite un token et une configuration).
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **Nouveauté :** La recherche, les tendances, les films ET les séries sont désormais **100% gratuits et sans aucun token** par défaut grâce aux plugins ZT, ZTNews et FreeTel.
|
> **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.
|
> 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
|
## ✨ Fonctionnalités
|
||||||
|
|
||||||
@@ -65,7 +85,7 @@ C'est la méthode la plus simple pour garder un environnement propre. Nous utili
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Cloner le projet (si ce n'est pas déjà fait)
|
# 1. Cloner le projet (si ce n'est pas déjà fait)
|
||||||
git clone https://gitlab.com/nonobzh22-group/Hydr-Hacked
|
git clone https://github.com/NoNoBzH22/Agora
|
||||||
|
|
||||||
# 2. Préparer la configuration
|
# 2. Préparer la configuration
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -76,7 +96,7 @@ docker compose up -d
|
|||||||
📍 Accès : `http://localhost:3067`
|
📍 Accès : `http://localhost:3067`
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> L'application utilise l'image `registry.gitlab.com/nonobzh22-group/hydr-hacked:latest`. Elle est reconstruite automatiquement à chaque mise à jour, vous n'avez plus besoin de compiler localement.
|
> 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,13 +132,20 @@ Créez un fichier `.env` à la racine du projet et configurez les variables suiv
|
|||||||
|
|
||||||
| Variable | Type | Description |
|
| Variable | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ZT_URL` | **Requis** | URL complète du site ZT. |
|
| `ZT_URL` | **Requis** | URL complète du site Zone-Telechargement. |
|
||||||
| `ZTNEWS_URL` | Optionnel | URL complète de la source ZTNews. |
|
| `ZTNEWS_URL` | Optionnel | URL complète de la source ZTNews. |
|
||||||
| `FT_URL` | Optionnel | URL complète de la source FreeTélécharger. |
|
| `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_URL` | Optionnel | URL complète de votre instance Hydracker (nécessaire si plugin actif). |
|
||||||
| `API_PASSWORD` | **Requis** | Mot de passe pour l'écran de connexion initial. |
|
|
||||||
| `SECRET` | **Requis** | Clé secrète pour les sessions. |
|
|
||||||
| `HYDRACKER_API_KEY` | Optionnel | Votre token Hydracker. |
|
| `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`). |
|
| `PORT` | Optionnel | Port de l'application (Défaut : `3067`). |
|
||||||
| `DB_PATH` | Optionnel | Chemin vers la base locale (Défaut : `./database/darkiworld.db`). |
|
| `DB_PATH` | Optionnel | Chemin vers la base locale (Défaut : `./database/darkiworld.db`). |
|
||||||
| `JD_HOST` | Optionnel | IP/Hôte de JDownloader. |
|
| `JD_HOST` | Optionnel | IP/Hôte de JDownloader. |
|
||||||
@@ -135,7 +162,7 @@ Créez un fichier `.env` à la racine du projet et configurez les variables suiv
|
|||||||
|
|
||||||
## 🧩 Créer un nouveau Plugin
|
## 🧩 Créer un nouveau Plugin
|
||||||
|
|
||||||
L'architecture d'Hydr'Hacked est modulaire. Vous pouvez facilement ajouter une nouvelle source en créant un plugin qui implémente l'interface `ISource`.
|
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
|
### 1. Structure
|
||||||
Créez un dossier dans `plugins/[NomDeVotreSource]/`. Vous aurez généralement besoin de :
|
Créez un dossier dans `plugins/[NomDeVotreSource]/`. Vous aurez généralement besoin de :
|
||||||
@@ -165,11 +192,8 @@ sourceRegistry.register(new VotrePluginAPI(CONFIG.VOTRE_URL));
|
|||||||
|
|
||||||
Le serveur découvrira et chargera automatiquement votre plugin au démarrage.
|
Le serveur découvrira et chargera automatiquement votre plugin au démarrage.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## 🤝 Un Projet Communautaire
|
## 🤝 Un Projet Communautaire
|
||||||
**Hydr'Hacked** 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.
|
**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".
|
Chaque Pull Request est la bienvenue, tant qu'elle contribue à rendre l'accès encore plus fluide et... disons, "généreux".
|
||||||
|
|
||||||
## Note Liminaire
|
## Note Liminaire
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+3
-4
@@ -1,8 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
hydrhacked:
|
agora:
|
||||||
image: registry.gitlab.com/nonobzh22-group/hydr-hacked:main
|
image: nonobzh22/agora:latest
|
||||||
build: .
|
container_name: agora_app
|
||||||
container_name: hydrhacked_app
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3067}:${PORT:-3067}"
|
- "${PORT:-3067}:${PORT:-3067}"
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hydrhacked",
|
"name": "Agora",
|
||||||
"version": "1.4.0",
|
"version": "1.5.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hydrhacked",
|
"name": "Agora",
|
||||||
"version": "1.4.0",
|
"version": "1.5.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "hydrhacked",
|
"name": "Agora",
|
||||||
"version": "1.4.0",
|
"version": "1.5.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Hydr'Hacked - API Proxy and Frontend",
|
"description": "Agora - API Proxy and Frontend",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --experimental-sqlite dist/src/index.js",
|
"start": "node --experimental-sqlite dist/src/index.js",
|
||||||
|
|||||||
+5
-7
@@ -30,13 +30,11 @@ function deduplicateByTitle(results: SearchResult[]): SearchResult[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ZTAPI implements ISource {
|
export class ZoneTelechargementAPI implements ISource {
|
||||||
name = 'zt';
|
name = 'zt';
|
||||||
displayName = 'ZT';
|
displayName = 'Zone-Téléchargement';
|
||||||
private baseUrl: string | undefined;
|
get baseUrl() {
|
||||||
|
return CONFIG.ZT_URL?.replace(/\/$/, '');
|
||||||
constructor(baseUrl?: string) {
|
|
||||||
this.baseUrl = baseUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async healthCheck(): Promise<boolean> {
|
async healthCheck(): Promise<boolean> {
|
||||||
@@ -161,4 +159,4 @@ export class ZTAPI implements ISource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Auto-registration ──
|
// ── Auto-registration ──
|
||||||
sourceRegistry.register(new ZTAPI(CONFIG.ZT_URL));
|
sourceRegistry.register(new ZoneTelechargementAPI());
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ export function parseSearchHTML(html: string, baseUrl: string | undefined): Sear
|
|||||||
type = 'anime';
|
type = 'anime';
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push({ title, image, hrefPath: href, year: null, type, source: 'zt' });
|
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;
|
return results;
|
||||||
@@ -80,7 +86,7 @@ export function parseContentHTML(html: string): ContentLinks {
|
|||||||
const cleanedLabel = label.replace(sizeRegex, "").trim();
|
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")
|
// 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|uptobox|rapidgator|nitroflare|send.now)/i.test(cleanedLabel);
|
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;
|
let episode = (!isGenericLabel && cleanedLabel.length > 3) ? cleanedLabel : undefined;
|
||||||
|
|
||||||
// SI le label est générique, on cherche un texte juste avant (ex: "Episode 1")
|
// SI le label est générique, on cherche un texte juste avant (ex: "Episode 1")
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { SearchResult, VideoLink, SeasonOption, SelectionData, ContentLinks } from '../../src/types/source.js';
|
||||||
|
import { FlixArtAuth } from './auth.js';
|
||||||
|
import { FlixArtParser } from './parser.js';
|
||||||
|
import { CONFIG } from '../../src/utils/config.js';
|
||||||
|
|
||||||
|
export class FlixArtAPI {
|
||||||
|
private static get baseUrl() { return CONFIG.FLIXART_URL || ''; }
|
||||||
|
private static get ajaxUrl() { return `${this.baseUrl}/wp-admin/admin-ajax.php`; }
|
||||||
|
private static userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
|
||||||
|
|
||||||
|
// Cache the film page data temporarily for resolveLink
|
||||||
|
private static contextCache: { [url: string]: { postId: string, nonce: string, type: string } } = {};
|
||||||
|
|
||||||
|
private static async fetchWithAuth(url: string, options: RequestInit = {}, retries = 1): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const cookie = await FlixArtAuth.getCookie();
|
||||||
|
|
||||||
|
const headers = new Headers(options.headers || {});
|
||||||
|
headers.set('User-Agent', this.userAgent);
|
||||||
|
headers.set('Cookie', cookie);
|
||||||
|
headers.set('Origin', this.baseUrl);
|
||||||
|
headers.set('Referer', this.baseUrl);
|
||||||
|
|
||||||
|
const response = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
|
// If FlixArt returns 403 or redirects to login, refresh cookie and retry
|
||||||
|
if (response.status === 403 && retries > 0) {
|
||||||
|
console.log('[FlixArt] Session expirée, renouvellement du cookie...');
|
||||||
|
await FlixArtAuth.getCookie(true);
|
||||||
|
return this.fetchWithAuth(url, options, retries - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (retries > 0) {
|
||||||
|
await FlixArtAuth.getCookie(true);
|
||||||
|
return this.fetchWithAuth(url, options, retries - 1);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async search(query: string, mediaType?: string): Promise<SearchResult[]> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
action: 'flixart_header_search',
|
||||||
|
s: query,
|
||||||
|
search: query,
|
||||||
|
type_query: 'all',
|
||||||
|
post_type: mediaType === 'series' ? 'tv_shows' : 'movies'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search works without auth, but we use fetchWithAuth just in case
|
||||||
|
const res = await this.fetchWithAuth(this.ajaxUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: body.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.data && data.data.results) {
|
||||||
|
return FlixArtParser.parseSearchAjax(data.data.results);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getTrending(mediaType: 'movie' | 'series'): Promise<SearchResult[]> {
|
||||||
|
const res = await this.fetchWithAuth(this.baseUrl, { method: 'GET' });
|
||||||
|
const html = await res.text();
|
||||||
|
return FlixArtParser.parseTrending(html, mediaType === 'series');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getSelection(url: string): Promise<SelectionData> {
|
||||||
|
const res = await this.fetchWithAuth(url, { method: 'GET' });
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
const parsed = FlixArtParser.parseSelection(html);
|
||||||
|
|
||||||
|
// Cache post data for resolveLink
|
||||||
|
if (parsed.postId && parsed.nonce) {
|
||||||
|
this.contextCache[url] = {
|
||||||
|
postId: parsed.postId,
|
||||||
|
nonce: parsed.nonce,
|
||||||
|
type: parsed.isSeries ? 'tv_shows' : 'movies' // Note: actually parser returns isSeries. Captcha needs 'movies' or 'tv_shows'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix ID with url to pass state to resolveLink
|
||||||
|
parsed.links.forEach((link: any) => {
|
||||||
|
link.id = `${url}|${link.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
links: parsed.links,
|
||||||
|
seasons: parsed.seasons,
|
||||||
|
isSeries: parsed.isSeries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getContentLinks(url: string, season?: number): Promise<ContentLinks> {
|
||||||
|
// Not used heavily if getSelection is prioritized, but we need to fetch the season HTML via ajax
|
||||||
|
// For simplicity, if season is passed, we fetch season content via AJAX.
|
||||||
|
// Actually, FlixArt loads all episodes HTML when you click a season tab.
|
||||||
|
// For now, getSelection is sufficient.
|
||||||
|
const selection = await this.getSelection(url);
|
||||||
|
return { links: selection.links };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { CONFIG } from '../../src/utils/config.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export class FlixArtAuth {
|
||||||
|
private static sessionCookie: string | null = null;
|
||||||
|
private static lastLoginTime: number = 0;
|
||||||
|
private static readonly COOKIE_FILE = path.resolve(process.cwd(), 'database', 'flixart_cookie.txt');
|
||||||
|
|
||||||
|
public static async getCookie(forceRefresh = false): Promise<string> {
|
||||||
|
if (!this.sessionCookie && fs.existsSync(this.COOKIE_FILE)) {
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(this.COOKIE_FILE);
|
||||||
|
// Si le cookie a moins de 7 jours, on le réutilise (le renouvellement se fera si on obtient une 403)
|
||||||
|
if (Date.now() - stats.mtimeMs < 7 * 24 * 60 * 60 * 1000) {
|
||||||
|
this.sessionCookie = fs.readFileSync(this.COOKIE_FILE, 'utf-8');
|
||||||
|
this.lastLoginTime = stats.mtimeMs;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[FlixArt Auth] Impossible de lire le cookie sauvegardé:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh && this.sessionCookie && Date.now() - this.lastLoginTime < 12 * 60 * 60 * 1000) {
|
||||||
|
return this.sessionCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = CONFIG.FLIXART_USERNAME;
|
||||||
|
const password = CONFIG.FLIXART_PASSWORD;
|
||||||
|
const baseUrl = CONFIG.FLIXART_URL || '';
|
||||||
|
const ajaxUrl = `${baseUrl}/wp-admin/admin-ajax.php`;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new Error('[FlixArt Auth] Identifiants manquants.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[FlixArt] Tentative de connexion avec l'utilisateur: ${username}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Obtenir un nouveau nonce de login
|
||||||
|
const refreshParams = new URLSearchParams({
|
||||||
|
action: 'flixart_auth_refresh_nonces'
|
||||||
|
});
|
||||||
|
const refreshRes = await fetch(ajaxUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Referer': baseUrl
|
||||||
|
},
|
||||||
|
body: refreshParams.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshData = await refreshRes.json();
|
||||||
|
if (!refreshData.success || !refreshData.data || !refreshData.data.loginNonce) {
|
||||||
|
throw new Error("Impossible d'obtenir le nonce de connexion.");
|
||||||
|
}
|
||||||
|
const loginNonce = refreshData.data.loginNonce;
|
||||||
|
const refreshCookies = (refreshRes.headers.getSetCookie ? refreshRes.headers.getSetCookie() : [refreshRes.headers.get('set-cookie') || '']).map(c => c.split(';')[0]).filter(Boolean);
|
||||||
|
const refreshCookieStr = refreshCookies.join('; ');
|
||||||
|
|
||||||
|
// 2. Se connecter
|
||||||
|
const dataParams = new URLSearchParams();
|
||||||
|
dataParams.append('log', username);
|
||||||
|
dataParams.append('pwd', password);
|
||||||
|
dataParams.append('redirect', baseUrl + '/membership-account/');
|
||||||
|
|
||||||
|
const loginParams = new URLSearchParams();
|
||||||
|
loginParams.append('action', 'flixart_auth_login');
|
||||||
|
loginParams.append('nonce', loginNonce);
|
||||||
|
loginParams.append('data', dataParams.toString());
|
||||||
|
|
||||||
|
const loginRes = await fetch(ajaxUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Origin': baseUrl,
|
||||||
|
'Referer': baseUrl + '/',
|
||||||
|
'Cookie': refreshCookieStr
|
||||||
|
},
|
||||||
|
body: loginParams.toString(),
|
||||||
|
redirect: 'manual'
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginBody = await loginRes.clone().json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (loginBody.success === false) {
|
||||||
|
const code = loginBody.data?.code || loginBody.data?.[0]?.code;
|
||||||
|
if (code === 'too_many_devices') {
|
||||||
|
console.log(`[FlixArt] ⚠️ Limite d'appareils atteinte. Tentative de libération...`);
|
||||||
|
const recoveryParams = new URLSearchParams({
|
||||||
|
action: 'flixart_device_recovery',
|
||||||
|
nonce: refreshData.data.deviceRecoveryNonce,
|
||||||
|
username: username,
|
||||||
|
password: password
|
||||||
|
});
|
||||||
|
await fetch(ajaxUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Referer': baseUrl,
|
||||||
|
'Cookie': refreshCookieStr
|
||||||
|
},
|
||||||
|
body: recoveryParams.toString()
|
||||||
|
});
|
||||||
|
console.log(`[FlixArt] ✅ Appareils libérés, nouvelle tentative de connexion...`);
|
||||||
|
return this.getCookie(true);
|
||||||
|
}
|
||||||
|
throw new Error(loginBody.data?.[0]?.message || loginBody.data?.message || 'Échec de la connexion.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlixArt returns 200 OK with success: true and sets cookies
|
||||||
|
const setCookieHeader = loginRes.headers.get('set-cookie') || loginRes.headers.get('Set-Cookie');
|
||||||
|
|
||||||
|
let cookies: string[] = [];
|
||||||
|
if (setCookieHeader) {
|
||||||
|
const setCookieHeaders = loginRes.headers.getSetCookie ? loginRes.headers.getSetCookie() : [setCookieHeader];
|
||||||
|
cookies = setCookieHeaders.map(c => c.split(';')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cookies.some(c => c.includes('wordpress_logged_in_'))) {
|
||||||
|
console.warn(`[FlixArt] ⚠️ Pas de cookie wordpress_logged_in trouvé.`);
|
||||||
|
throw new Error('Échec de la connexion (Pas de cookie de session complet).');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionCookie = cookies.join('; ');
|
||||||
|
this.lastLoginTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(this.COOKIE_FILE, this.sessionCookie, 'utf-8');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[FlixArt Auth] Impossible de sauvegarder le cookie:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[FlixArt] ✅ Connexion réussie ! (Cookie généré)`);
|
||||||
|
return this.sessionCookie;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[FlixArt] ❌ Erreur lors de la connexion:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ISource, SearchResult, SelectionData, ContentLinks, MediaType } from '../../src/types/source.js';
|
||||||
|
import { FlixArtAPI } from './api.js';
|
||||||
|
|
||||||
|
export class FlixartSource implements ISource {
|
||||||
|
name = 'flixart';
|
||||||
|
displayName = 'FlixArt';
|
||||||
|
|
||||||
|
async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
|
||||||
|
return FlixArtAPI.search(query, mediaType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||||
|
const type = mediaType === 'series' ? 'series' : 'movie';
|
||||||
|
return FlixArtAPI.getTrending(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecent(): Promise<SearchResult[]> {
|
||||||
|
return this.getTrending('movie');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||||
|
return FlixArtAPI.getSelection(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
|
||||||
|
return FlixArtAPI.getContentLinks(identifier, season);
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const results = await this.getTrending('movie');
|
||||||
|
return results.length > 0;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[FlixArt] Healthcheck failed: ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom resolveLink that returns a Turnstile challenge instead of just the URL
|
||||||
|
// Actually, Agora's activeSource.resolveLink only accepts string.
|
||||||
|
// We will change ISource resolveLink to allow returning an object.
|
||||||
|
async resolveLink(linkId: string, extraData?: any): Promise<any> {
|
||||||
|
const [url] = linkId.split('|');
|
||||||
|
// FlixArt requires a Cloudflare Turnstile challenge which cannot be resolved on localhost.
|
||||||
|
// We directly return the manual redirection challenge.
|
||||||
|
return {
|
||||||
|
captcha: 'turnstile',
|
||||||
|
url: url,
|
||||||
|
sourceName: 'FlixArt'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-registration
|
||||||
|
import { sourceRegistry } from '../../src/core/registry.js';
|
||||||
|
sourceRegistry.register(new FlixartSource());
|
||||||
@@ -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 = /<a class="[^"]*flixart-search-card[^"]*" href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = cardRegex.exec(htmlStr)) !== null) {
|
||||||
|
const href = unescapeHtml(match[1]);
|
||||||
|
const inner = match[2];
|
||||||
|
|
||||||
|
let title = '';
|
||||||
|
const titleMatch = inner.match(/<span class="flixart-search-result-title">([^<]+)<\/span>/);
|
||||||
|
if (titleMatch) title = unescapeHtml(titleMatch[1].trim());
|
||||||
|
|
||||||
|
let year = null;
|
||||||
|
const yearMatch = inner.match(/<span class="video-years">([^<]+)<\/span>/);
|
||||||
|
if (yearMatch) year = yearMatch[1].trim();
|
||||||
|
|
||||||
|
let image = null;
|
||||||
|
const imgMatch = inner.match(/<img[^>]+src="([^"]+)"/);
|
||||||
|
if (imgMatch) {
|
||||||
|
image = unescapeHtml(imgMatch[1]);
|
||||||
|
if (image.includes('&quality=')) image = image.split('&quality=')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title && href) {
|
||||||
|
results.push({
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
image,
|
||||||
|
hrefPath: href,
|
||||||
|
type: getMediaTypeFromUrl(href),
|
||||||
|
source: 'flixart'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
static parseTrending(htmlStr: string, isSeries: boolean): SearchResult[] {
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
const sectionTitle = isSeries ? 'Top 10 séries du jour' : 'Top 10 films du jour';
|
||||||
|
const fallbackTitle = isSeries ? 'Nouveautés séries' : 'Nouveautés films';
|
||||||
|
|
||||||
|
// Find section containing the title
|
||||||
|
let sectionRegexStr = `<section class="fx-section">\\s*<div class="fx-section-head">\\s*<h2>(${sectionTitle}|${fallbackTitle})<\\/h2>[\\s\\S]*?<\\/section>`;
|
||||||
|
let sectionMatch = htmlStr.match(new RegExp(sectionRegexStr, 'i'));
|
||||||
|
|
||||||
|
if (!sectionMatch) return results;
|
||||||
|
const sectionHtml = sectionMatch[0];
|
||||||
|
|
||||||
|
const cardRegex = /<article class="fx-card[^"]*">([\s\S]*?)<\/article>/g;
|
||||||
|
let match;
|
||||||
|
while ((match = cardRegex.exec(sectionHtml)) !== null) {
|
||||||
|
const inner = match[1];
|
||||||
|
|
||||||
|
let href = null;
|
||||||
|
let title = '';
|
||||||
|
const titleMatch = inner.match(/<h3 class="fx-card-title"><a href="([^"]+)"[^>]*>([^<]+)<\/a><\/h3>/);
|
||||||
|
if (titleMatch) {
|
||||||
|
href = unescapeHtml(titleMatch[1]);
|
||||||
|
title = unescapeHtml(titleMatch[2].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
let image = null;
|
||||||
|
const imgMatch = inner.match(/<img src="([^"]+)"/);
|
||||||
|
if (imgMatch) {
|
||||||
|
image = unescapeHtml(imgMatch[1]);
|
||||||
|
if (image.includes('&quality=')) image = image.split('&quality=')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
let year = null;
|
||||||
|
const yearMatch = inner.match(/<span>(\d{4})<\/span>\s*<\/span>/);
|
||||||
|
if (yearMatch) {
|
||||||
|
year = yearMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title && href) {
|
||||||
|
results.push({
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
image,
|
||||||
|
hrefPath: href,
|
||||||
|
type: isSeries ? 'series' : 'movie',
|
||||||
|
source: 'flixart'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
static parseSelection(htmlStr: string): { links: VideoLink[], seasons: SeasonOption[], isSeries: boolean, postId: string, nonce: string | null } {
|
||||||
|
const links: VideoLink[] = [];
|
||||||
|
const seasons: SeasonOption[] = [];
|
||||||
|
let isSeries = false;
|
||||||
|
|
||||||
|
let postId = '';
|
||||||
|
const postIdMatch = htmlStr.match(/data-post-id="(\d+)"/);
|
||||||
|
if (postIdMatch) postId = postIdMatch[1];
|
||||||
|
|
||||||
|
let nonce: string | null = null;
|
||||||
|
const nonceMatch = htmlStr.match(/flixartDownloadCaptcha\s*=\s*\{[^}]*nonce:\s*'([^']+)'/);
|
||||||
|
if (nonceMatch) nonce = nonceMatch[1];
|
||||||
|
|
||||||
|
const seasonTabRegex = /<button[^>]+class="[^"]*flixart-season-tab[^"]*"[^>]+data-season="([^"]+)"[^>]*>([\s\S]*?)<\/button>/g;
|
||||||
|
let match;
|
||||||
|
while ((match = seasonTabRegex.exec(htmlStr)) !== null) {
|
||||||
|
isSeries = true;
|
||||||
|
const val = match[1];
|
||||||
|
const inner = match[2];
|
||||||
|
const numMatch = inner.match(/<span class="flixart-season-tab__number">([^<]+)<\/span>/);
|
||||||
|
if (numMatch) {
|
||||||
|
seasons.push({ label: `Saison ${numMatch[1].trim()}`, value: val });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowRegex = /<div role="row" class="jws-lien-row[^"]*"[^>]*data-qualite="([^"]*)"[^>]*data-langue="([^"]*)"[^>]*>([\s\S]*?)<\/div>/g;
|
||||||
|
while ((match = rowRegex.exec(htmlStr)) !== null) {
|
||||||
|
const inner = match[3];
|
||||||
|
let episode = null;
|
||||||
|
const episodeMatch = htmlStr.substring(match.index - 100, match.index).match(/data-episode="([^"]+)"/);
|
||||||
|
if (episodeMatch) episode = episodeMatch[1];
|
||||||
|
|
||||||
|
const checkboxMatch = inner.match(/<input[^>]+data-flixart-download-select[^>]+data-row-index="(\d+)"[^>]*data-download-title="([^"]*)"[^>]*data-download-meta="([^"]*)"/);
|
||||||
|
if (checkboxMatch) {
|
||||||
|
const rowIndex = checkboxMatch[1];
|
||||||
|
const title = unescapeHtml(checkboxMatch[2]);
|
||||||
|
const meta = unescapeHtml(checkboxMatch[3]);
|
||||||
|
|
||||||
|
let host = 'Inconnu';
|
||||||
|
const lowerMeta = meta.toLowerCase();
|
||||||
|
if (lowerMeta.includes('1fichier')) host = '1fichier';
|
||||||
|
else if (lowerMeta.includes('nitroflare')) host = 'nitroflare';
|
||||||
|
else if (lowerMeta.includes('ddownload')) host = 'ddownload';
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
id: rowIndex,
|
||||||
|
host,
|
||||||
|
label: title,
|
||||||
|
quality: meta,
|
||||||
|
url: null,
|
||||||
|
episode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { links, seasons, isSeries, postId, nonce };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,8 @@ function isSeriesIdentifier(identifier: string): boolean {
|
|||||||
export class FreeTeleAPI implements ISource {
|
export class FreeTeleAPI implements ISource {
|
||||||
name = 'freetel';
|
name = 'freetel';
|
||||||
displayName = 'Free-Télécharger';
|
displayName = 'Free-Télécharger';
|
||||||
private baseUrl: string | undefined;
|
get baseUrl() {
|
||||||
|
return CONFIG.FT_URL?.replace(/\/$/, '');
|
||||||
constructor(baseUrl?: string) {
|
|
||||||
this.baseUrl = baseUrl?.replace(/\/$/, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async healthCheck(): Promise<boolean> {
|
async healthCheck(): Promise<boolean> {
|
||||||
@@ -114,7 +112,7 @@ export class FreeTeleAPI implements ISource {
|
|||||||
let hostUrl: string | null = null;
|
let hostUrl: string | null = null;
|
||||||
|
|
||||||
// Cas série : page intermédiaire liens.free-telecharger.cam/SLUG-episode_N
|
// Cas série : page intermédiaire liens.free-telecharger.cam/SLUG-episode_N
|
||||||
if (linkId.includes('liens.free-telecharger.cam')) {
|
if (linkId.includes('liens.free-telecharger.')) {
|
||||||
try {
|
try {
|
||||||
const html = await fetchPage(linkId);
|
const html = await fetchPage(linkId);
|
||||||
const hosts = parseEpisodeLinks(html);
|
const hosts = parseEpisodeLinks(html);
|
||||||
@@ -139,4 +137,4 @@ export class FreeTeleAPI implements ISource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceRegistry.register(new FreeTeleAPI(CONFIG.FT_URL));
|
sourceRegistry.register(new FreeTeleAPI());
|
||||||
|
|||||||
@@ -64,9 +64,17 @@ function detectType(href: string): 'movie' | 'series' | 'anime' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function absUrl(url: string, baseUrl: string): string {
|
function absUrl(url: string, baseUrl: string): string {
|
||||||
if (url.startsWith('http')) return url;
|
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(/\/$/, '');
|
const cleanedBase = baseUrl.replace(/\/$/, '');
|
||||||
return cleanedBase + '/' + url.replace(/^\//, '');
|
return cleanedBase + '/' + path.replace(/^\//, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,9 +91,15 @@ export function parseSearchResults(html: string, baseUrl: string): SearchResult[
|
|||||||
const href = absUrl(hrefRaw, baseUrl);
|
const href = absUrl(hrefRaw, baseUrl);
|
||||||
const title = m[3]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
const title = m[3]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||||
if (!title) continue;
|
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({
|
results.push({
|
||||||
title,
|
title,
|
||||||
year: null,
|
year,
|
||||||
image,
|
image,
|
||||||
hrefPath: href,
|
hrefPath: href,
|
||||||
type: detectType(hrefRaw),
|
type: detectType(hrefRaw),
|
||||||
@@ -106,9 +120,15 @@ export function parseTrendingResults(html: string, baseUrl: string): SearchResul
|
|||||||
const hrefRaw = m[1]!;
|
const hrefRaw = m[1]!;
|
||||||
const title = m[2]!.trim();
|
const title = m[2]!.trim();
|
||||||
const image = absUrl(m[3]!, baseUrl);
|
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({
|
results.push({
|
||||||
title,
|
title,
|
||||||
year: null,
|
year,
|
||||||
image,
|
image,
|
||||||
hrefPath: absUrl(hrefRaw, baseUrl),
|
hrefPath: absUrl(hrefRaw, baseUrl),
|
||||||
type: detectType(hrefRaw),
|
type: detectType(hrefRaw),
|
||||||
@@ -127,7 +147,7 @@ export function parseContentHTML(html: string, isSeries: boolean): ContentLinks
|
|||||||
const links: VideoLink[] = [];
|
const links: VideoLink[] = [];
|
||||||
|
|
||||||
if (isSeries) {
|
if (isSeries) {
|
||||||
const episodeRegex = /<input[^>]+name="lien"\s+value="(https?:\/\/liens\.free-telecharger\.cam\/[^"]+)"/gi;
|
const episodeRegex = /<input[^>]+name="lien"\s+value="(https?:\/\/liens\.free-telecharger\.[a-z]+\/[^"]+)"/gi;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
while ((m = episodeRegex.exec(html)) !== null) {
|
while ((m = episodeRegex.exec(html)) !== null) {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { FS24Auth } from './auth.js';
|
||||||
|
import { CONFIG } from '../../src/utils/config.js';
|
||||||
|
|
||||||
|
export class FS24API {
|
||||||
|
private static get baseUrl(): string {
|
||||||
|
return CONFIG.FS24_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche AJAX via /engine/ajax/search.php
|
||||||
|
*/
|
||||||
|
public static async fetchSearch(query: string, page: number = 1): Promise<string> {
|
||||||
|
const cookie = await FS24Auth.getCookie();
|
||||||
|
const searchUrl = `${this.baseUrl}/engine/ajax/search.php`;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('query', query);
|
||||||
|
params.append('page', page.toString());
|
||||||
|
|
||||||
|
console.log(`[FS24] Recherche: "${query}" (page ${page})`);
|
||||||
|
|
||||||
|
const response = await fetch(searchUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||||
|
'Cookie': cookie,
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP Error ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la page HTML d'un contenu pour extraire le news_id
|
||||||
|
*/
|
||||||
|
public static async fetchPage(pathOrUrl: string): Promise<string> {
|
||||||
|
const cookie = await FS24Auth.getCookie();
|
||||||
|
const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${this.baseUrl}${pathOrUrl.startsWith('/') ? '' : '/'}${pathOrUrl}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||||
|
'Cookie': cookie
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP Error ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la page des tendances (films ou séries)
|
||||||
|
*/
|
||||||
|
public static async fetchTrending(mediaType: 'movie' | 'series'): Promise<string> {
|
||||||
|
const cookie = await FS24Auth.getCookie();
|
||||||
|
const url = mediaType === 'series' ? `${this.baseUrl}/s-tv/` : `${this.baseUrl}/films/`;
|
||||||
|
|
||||||
|
console.log(`[FS24] Chargement des tendances ${mediaType}`);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||||
|
'Cookie': cookie
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP Error ${response.status}`);
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la page des ajouts récents
|
||||||
|
*/
|
||||||
|
public static async fetchRecent(): Promise<string> {
|
||||||
|
const cookie = await FS24Auth.getCookie();
|
||||||
|
const url = `${this.baseUrl}/film-commu/`;
|
||||||
|
|
||||||
|
console.log(`[FS24] Chargement des ajouts récents`);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||||
|
'Cookie': cookie
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP Error ${response.status}`);
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appelle l'API JSON /engine/ajax/release-api.php pour récupérer les releases communautaires.
|
||||||
|
* C'est ici que se trouvent les vrais liens de téléchargement (fsprotect encodés en base64).
|
||||||
|
*/
|
||||||
|
public static async fetchReleases(newsId: string): Promise<any> {
|
||||||
|
const cookie = await FS24Auth.getCookie();
|
||||||
|
const url = `${this.baseUrl}/engine/ajax/release-api.php?action=release_list&post_id=${newsId}`;
|
||||||
|
|
||||||
|
console.log(`[FS24] Chargement des releases pour post_id=${newsId}`);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
|
||||||
|
'Cookie': cookie,
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP Error ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { CONFIG } from '../../src/utils/config.js';
|
||||||
|
|
||||||
|
export class FS24Auth {
|
||||||
|
private static sessionCookie: string | null = null;
|
||||||
|
private static lastLoginTime: number = 0;
|
||||||
|
|
||||||
|
public static async getCookie(forceRefresh = false): Promise<string> {
|
||||||
|
// If we already have a cookie and it's less than 12 hours old, return it
|
||||||
|
if (!forceRefresh && this.sessionCookie && Date.now() - this.lastLoginTime < 12 * 60 * 60 * 1000) {
|
||||||
|
return this.sessionCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = CONFIG.FS24_USERNAME;
|
||||||
|
const password = CONFIG.FS24_PASSWORD;
|
||||||
|
const baseUrl = CONFIG.FS24_URL;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new Error('[FS24 Auth] Identifiants manquants.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[FS24] Tentative de connexion avec l'utilisateur: ${username}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('login_name', username);
|
||||||
|
params.append('login_password', password);
|
||||||
|
params.append('login', 'submit');
|
||||||
|
|
||||||
|
const response = await fetch(baseUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
'Referer': baseUrl
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
redirect: 'manual' // Capture the set-cookie from the redirect
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect cookies from the response headers
|
||||||
|
const setCookieHeader = response.headers.get('set-cookie');
|
||||||
|
if (setCookieHeader) {
|
||||||
|
// Parse DLE / PHP session cookies
|
||||||
|
const cookies = setCookieHeader.split(',').map(c => c.split(';')[0].trim());
|
||||||
|
this.sessionCookie = cookies.join('; ');
|
||||||
|
this.lastLoginTime = Date.now();
|
||||||
|
console.log(`[FS24] ✅ Connexion réussie ! (Cookie généré)`);
|
||||||
|
return this.sessionCookie;
|
||||||
|
} else {
|
||||||
|
console.warn(`[FS24] ⚠️ Pas de header set-cookie retourné. Les identifiants sont-ils valides ?`);
|
||||||
|
throw new Error('Échec de la connexion (Pas de cookie de session).');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[FS24] ❌ Erreur lors de la connexion:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { ISource, SearchResult, ContentLinks, MediaType, SelectionData } from '../../src/types/source.js';
|
||||||
|
import { sourceRegistry } from '../../src/core/registry.js';
|
||||||
|
import { FS24API } from './api.js';
|
||||||
|
import { FS24Auth } from './auth.js';
|
||||||
|
import { parseListingHTML, extractNewsId, parseReleasesJSON } from './parser.js';
|
||||||
|
|
||||||
|
export class FS24Source implements ISource {
|
||||||
|
public readonly name = 'fs24';
|
||||||
|
public readonly displayName = 'FS24';
|
||||||
|
|
||||||
|
public async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await FS24Auth.getCookie(true);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[FS24] HealthCheck échoué: ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
|
||||||
|
if (!query || query.length < 3) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const html = await FS24API.fetchSearch(query);
|
||||||
|
const results = parseListingHTML(html, mediaType || 'movie');
|
||||||
|
return results;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[FS24] Erreur search: ${e.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||||
|
try {
|
||||||
|
const html = await FS24API.fetchTrending(mediaType === 'series' ? 'series' : 'movie');
|
||||||
|
const results = parseListingHTML(html, mediaType);
|
||||||
|
return results.slice(0, 20); // Keep top 20
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[FS24] Erreur trending: ${e.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecent(): Promise<SearchResult[]> {
|
||||||
|
try {
|
||||||
|
const html = await FS24API.fetchRecent();
|
||||||
|
const results = parseListingHTML(html, 'movie'); // Default to movie for recents, TMDB will fix it if needed
|
||||||
|
return results.slice(0, 20);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[FS24] Erreur recent: ${e.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
|
||||||
|
try {
|
||||||
|
// Step 1: Fetch the page HTML to extract the news_id
|
||||||
|
const html = await FS24API.fetchPage(identifier);
|
||||||
|
const newsId = extractNewsId(html);
|
||||||
|
|
||||||
|
if (!newsId) {
|
||||||
|
console.warn(`[FS24] Impossible d'extraire le news_id depuis: ${identifier}`);
|
||||||
|
return { links: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Call the release JSON API to get the actual download links
|
||||||
|
const releaseData = await FS24API.fetchReleases(newsId);
|
||||||
|
const links = parseReleasesJSON(releaseData);
|
||||||
|
|
||||||
|
console.log(`[FS24] ${links.length} lien(s) trouvé(s) pour post_id=${newsId}`);
|
||||||
|
return { links };
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[FS24] Erreur getContentLinks: ${e.message}`);
|
||||||
|
return { links: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||||
|
const content = await this.getContentLinks(identifier);
|
||||||
|
|
||||||
|
return {
|
||||||
|
links: content.links,
|
||||||
|
seasons: [],
|
||||||
|
isSeries: type === 'series'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-registration ──
|
||||||
|
sourceRegistry.register(new FS24Source());
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { SearchResult, VideoLink, MediaType } from '../../src/types/source.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse le HTML AJAX de résultats de recherche ou pages catégories FS24.
|
||||||
|
* Supporte les blocs `.search-item` et `.short`
|
||||||
|
*/
|
||||||
|
export function parseListingHTML(html: string, mediaType: MediaType): SearchResult[] {
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
|
||||||
|
// 1. Matches pour les blocs de recherche AJAX (.search-item)
|
||||||
|
const searchRegex = /<div class=['"]search-item['"][^>]*onclick="location\.href='([^']+)'"[^>]*>([\s\S]*?)(?=<div class=['"]search-item['"]|$)/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = searchRegex.exec(html)) !== null) {
|
||||||
|
const hrefPath = match[1]!;
|
||||||
|
const block = match[2]!;
|
||||||
|
|
||||||
|
const imgMatch = block.match(/<img\s[^>]*src=['"]([^'"]+)['"]/);
|
||||||
|
const image = imgMatch ? imgMatch[1]! : null;
|
||||||
|
|
||||||
|
const titleMatch = block.match(/<div class=['"]search-title['"]>([^<]+)<\/div>/);
|
||||||
|
if (!titleMatch) continue;
|
||||||
|
|
||||||
|
let titleRaw = titleMatch[1]!.trim();
|
||||||
|
let year: string | null = null;
|
||||||
|
const yearMatch = titleRaw.match(/\((\d{4})\)/);
|
||||||
|
if (yearMatch) {
|
||||||
|
year = yearMatch[1]!;
|
||||||
|
titleRaw = titleRaw.replace(/\s*\(\d{4}\)\s*/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleRaw && hrefPath) {
|
||||||
|
results.push({ title: titleRaw, year, image, hrefPath, type: mediaType, source: 'fs24' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Matches pour les pages régulières DLE (.short)
|
||||||
|
const shortRegex = /<div class=['"]short['"]>([\s\S]*?)<\/div>\s*<!-- \/short -->|<div class=['"]short['"]>([\s\S]*?)(?=<div class=['"]short['"]|$)/g;
|
||||||
|
while ((match = shortRegex.exec(html)) !== null) {
|
||||||
|
const block = match[1] || match[2];
|
||||||
|
if (!block) continue;
|
||||||
|
|
||||||
|
// Extract poster
|
||||||
|
const imgMatch = block.match(/<img\s[^>]*src=['"]([^'"]+)['"]/);
|
||||||
|
const image = imgMatch ? imgMatch[1]! : null;
|
||||||
|
|
||||||
|
// Extract title
|
||||||
|
const titleMatch = block.match(/<div class=['"]short-title['"]>([^<]+)<\/div>/);
|
||||||
|
if (!titleMatch) continue;
|
||||||
|
let titleRaw = titleMatch[1]!.trim();
|
||||||
|
|
||||||
|
// Extract link
|
||||||
|
const linkMatch = block.match(/<a class=['"]short-poster[^>]*href=['"]([^'"]+)['"]/);
|
||||||
|
let hrefPath = linkMatch ? linkMatch[1]! : null;
|
||||||
|
if (!hrefPath) continue;
|
||||||
|
|
||||||
|
// Remove domain if the link is absolute to keep paths source-agnostic
|
||||||
|
if (hrefPath.startsWith('http')) {
|
||||||
|
try {
|
||||||
|
const u = new URL(hrefPath);
|
||||||
|
hrefPath = u.pathname + u.search;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let year: string | null = null;
|
||||||
|
const yearMatch = titleRaw.match(/\((\d{4})\)/);
|
||||||
|
if (yearMatch) {
|
||||||
|
year = yearMatch[1]!;
|
||||||
|
titleRaw = titleRaw.replace(/\s*\(\d{4}\)\s*/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleRaw && hrefPath) {
|
||||||
|
results.push({ title: titleRaw, year, image, hrefPath, type: mediaType, source: 'fs24' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait le news_id depuis la page HTML (attribut data-news-id du bloc commu-releases-block).
|
||||||
|
*/
|
||||||
|
export function extractNewsId(html: string): string | null {
|
||||||
|
const match = html.match(/data-news-id="(\d+)"/);
|
||||||
|
return match ? match[1]! : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Décode un lien fsprotect double-Base64 en URL finale.
|
||||||
|
* Format: base64 → "url:<second_b64>|metadata|timestamp|hash"
|
||||||
|
* second_b64 → URL finale (ex: https://1fichier.com/...)
|
||||||
|
*/
|
||||||
|
export function decodeFsProtectLink(rawHref: string): string | null {
|
||||||
|
try {
|
||||||
|
// Extract the ?t= parameter
|
||||||
|
const tParamMatch = rawHref.match(/[?&]t=([^&]+)/);
|
||||||
|
if (!tParamMatch) return null;
|
||||||
|
|
||||||
|
const base64t = tParamMatch[1]!;
|
||||||
|
// First Base64 decode
|
||||||
|
const decodedT = Buffer.from(base64t, 'base64').toString('utf-8');
|
||||||
|
// Format: url:<second_base64>|<metadata>|<timestamp>|<hash>
|
||||||
|
if (!decodedT.startsWith('url:')) return null;
|
||||||
|
|
||||||
|
const firstPart = decodedT.substring(4).split('|')[0]!;
|
||||||
|
if (!firstPart) return null;
|
||||||
|
|
||||||
|
// Second Base64 decode → final URL
|
||||||
|
return Buffer.from(firstPart, 'base64').toString('utf-8');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[FS24] Erreur décodage lien Base64:', e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes || bytes <= 0) return '';
|
||||||
|
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(2) + ' GB';
|
||||||
|
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
|
||||||
|
return (bytes / 1024).toFixed(0) + ' KB';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse la réponse JSON de l'API release-api.php en VideoLink[].
|
||||||
|
*/
|
||||||
|
export function parseReleasesJSON(data: any): VideoLink[] {
|
||||||
|
const links: VideoLink[] = [];
|
||||||
|
if (!data || !data.ok || !Array.isArray(data.items)) return links;
|
||||||
|
|
||||||
|
for (const item of data.items) {
|
||||||
|
const rawLink = item.original_link || '';
|
||||||
|
const finalUrl = decodeFsProtectLink(rawLink);
|
||||||
|
if (!finalUrl) continue;
|
||||||
|
|
||||||
|
const releaseName = item.release_name || 'Inconnu';
|
||||||
|
const lowerName = releaseName.toLowerCase();
|
||||||
|
|
||||||
|
// Detect language from release name
|
||||||
|
const langs: string[] = [];
|
||||||
|
if (lowerName.includes('multi')) langs.push('vf', 'vostfr');
|
||||||
|
else if (lowerName.includes('vostfr')) langs.push('vostfr');
|
||||||
|
else if (lowerName.includes('truefrench') || lowerName.includes('french')) langs.push('vf');
|
||||||
|
else langs.push('vf');
|
||||||
|
|
||||||
|
// Detect host from URL
|
||||||
|
let host = 'Inconnu';
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(finalUrl);
|
||||||
|
host = urlObj.hostname.replace('www.', '');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
id: String(item.id),
|
||||||
|
host,
|
||||||
|
url: finalUrl,
|
||||||
|
quality: item.quality || '',
|
||||||
|
size: formatBytes(item.size_bytes),
|
||||||
|
releaseName: item.is_team ? `[TEAM] ${releaseName}` : releaseName,
|
||||||
|
langs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}
|
||||||
+86
-34
@@ -1,18 +1,18 @@
|
|||||||
import { CONFIG } from '../../src/utils/config.js';
|
import { CONFIG } from '../../src/utils/config.js';
|
||||||
|
|
||||||
export const CONFIG_HYDRACKER = {
|
export const CONFIG_HYDRACKER = {
|
||||||
BASE_URL: (CONFIG.HYDRACKER_URL || '').replace(/\/$/, ''), // Supprime le slash final
|
get BASE_URL() { return (CONFIG.HYDRACKER_URL || '').replace(/\/$/, ''); },
|
||||||
API_KEY: CONFIG.HYDRACKER_API_KEY,
|
get API_KEY() { return CONFIG.HYDRACKER_API_KEY; },
|
||||||
TIMEOUT: CONFIG.HYDRACKER_TIMEOUT || 15000,
|
get TIMEOUT() { return CONFIG.HYDRACKER_TIMEOUT || 15000; },
|
||||||
};
|
};
|
||||||
|
|
||||||
const HYDRACKER_HEADERS = {
|
export function getHydrackerHeaders() {
|
||||||
'Accept': 'application/json',
|
return {
|
||||||
'Authorization': `Bearer ${CONFIG_HYDRACKER.API_KEY}`,
|
'Accept': 'application/json',
|
||||||
'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'
|
'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'
|
||||||
|
};
|
||||||
const TIMEOUT = CONFIG_HYDRACKER.TIMEOUT; // 30 secondes par défaut (configurable)
|
}
|
||||||
|
|
||||||
async function fetchWithRetry(
|
async function fetchWithRetry(
|
||||||
url: string,
|
url: string,
|
||||||
@@ -26,7 +26,7 @@ async function fetchWithRetry(
|
|||||||
while (true) {
|
while (true) {
|
||||||
attempt++;
|
attempt++;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
|
const timeoutId = setTimeout(() => controller.abort(), CONFIG_HYDRACKER.TIMEOUT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -60,11 +60,13 @@ async function fetchWithRetry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiGet(urlPath: string, params: Record<string, any> = {}) {
|
export async function apiGet(urlPath: string, params: Record<string, any> = {}) {
|
||||||
const qs = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
|
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}` : '');
|
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/${urlPath}` + (qs ? `?${qs}` : '');
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithRetry(url, {
|
const res = await fetchWithRetry(url, {
|
||||||
headers: HYDRACKER_HEADERS
|
headers: getHydrackerHeaders()
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`[Hydracker-API] apiGet HTTP ${res.status} on ${urlPath}`);
|
console.error(`[Hydracker-API] apiGet HTTP ${res.status} on ${urlPath}`);
|
||||||
@@ -82,7 +84,7 @@ export async function apiPost(urlPath: string, body: any = {}) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetchWithRetry(url, {
|
const res = await fetchWithRetry(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...HYDRACKER_HEADERS, 'Content-Type': 'application/json' },
|
headers: { ...getHydrackerHeaders(), 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
return { status: res.status, body: await res.text() };
|
return { status: res.status, body: await res.text() };
|
||||||
@@ -93,16 +95,21 @@ export async function apiPost(urlPath: string, body: any = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSearch(query: string) {
|
export async function fetchSearch(query: string) {
|
||||||
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/search/${encodeURIComponent(query)}?loader=searchAutocomplete`;
|
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/titles?query=${encodeURIComponent(query)}`;
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithRetry(url, {
|
const res = await fetchWithRetry(url, {
|
||||||
headers: HYDRACKER_HEADERS
|
headers: getHydrackerHeaders()
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`[Hydracker-API] Search HTTP ${res.status} for "${query}"`);
|
console.error(`[Hydracker-API] Search HTTP ${res.status} for "${query}"`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await res.json();
|
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) {
|
} catch (e: any) {
|
||||||
console.error('[Hydracker-API] Search failed:', e.message);
|
console.error('[Hydracker-API] Search failed:', e.message);
|
||||||
return null;
|
return null;
|
||||||
@@ -113,7 +120,7 @@ export async function fetchMovieLinks(titleId: string) {
|
|||||||
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/titles/${titleId}/download`;
|
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/titles/${titleId}/download`;
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithRetry(url, {
|
const res = await fetchWithRetry(url, {
|
||||||
headers: HYDRACKER_HEADERS
|
headers: getHydrackerHeaders()
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return await res.json();
|
return await res.json();
|
||||||
@@ -122,22 +129,67 @@ export async function fetchMovieLinks(titleId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeriesLiens(titleId: string, season: number = 1) {
|
/**
|
||||||
const allLiens: any[] = [];
|
* Récupère la page de download d'un titre.
|
||||||
let page = 1;
|
* - Films : GET /titles/{id}/download
|
||||||
while (true) {
|
* - Séries : GET /titles/{id}/season/{s}/episode/{e}/download
|
||||||
const result = await apiGet('liens', {
|
*
|
||||||
title_id: titleId, loader: 'linksdl', season,
|
* Retourne l'objet complet contenant: video, alternative_videos, title.seasons, last_episode, etc.
|
||||||
perPage: 500, page, filters: '', paginate: 'lengthAware'
|
*/
|
||||||
});
|
export async function fetchDownloadPage(titleId: string, season?: number, episode?: number) {
|
||||||
if (!result || result.error) break;
|
let urlPath: string;
|
||||||
const pagination = result.pagination || {};
|
if (season && season > 0 && episode && episode > 0) {
|
||||||
const data = pagination.data || [];
|
urlPath = `titles/${titleId}/season/${season}/episode/${episode}/download`;
|
||||||
if (!data.length) break;
|
} else if (season && season > 0) {
|
||||||
allLiens.push(...data);
|
// On demande le premier épisode de la saison pour obtenir les métadonnées
|
||||||
const lastPage = pagination.last_page || pagination.lastPage || 1;
|
urlPath = `titles/${titleId}/season/${season}/episode/1/download`;
|
||||||
if (page >= lastPage) break;
|
} else {
|
||||||
page++;
|
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;
|
return allLiens;
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-75
@@ -1,6 +1,6 @@
|
|||||||
import { ISource, SearchResult, MediaType, ContentLinks, VideoLink, SelectionData } from '../../src/types/source.js';
|
import { ISource, SearchResult, MediaType, ContentLinks, VideoLink, SelectionData } from '../../src/types/source.js';
|
||||||
import { sourceRegistry } from '../../src/core/registry.js';
|
import { sourceRegistry } from '../../src/core/registry.js';
|
||||||
import { CONFIG_HYDRACKER, apiGet, apiPost, fetchSearch, fetchMovieLinks, fetchSeriesLiens } from './api.js';
|
import { CONFIG_HYDRACKER, apiGet, apiPost, fetchSearch, fetchDownloadPage, fetchSeriesLiens } from './api.js';
|
||||||
import {
|
import {
|
||||||
QUALITY_MAP, formatSize,
|
QUALITY_MAP, formatSize,
|
||||||
parseSearchResults, parseTrendingResults,
|
parseSearchResults, parseTrendingResults,
|
||||||
@@ -13,19 +13,8 @@ export class HydrackerAPI implements ISource {
|
|||||||
displayName = 'Hydracker (Token)';
|
displayName = 'Hydracker (Token)';
|
||||||
|
|
||||||
async healthCheck(): Promise<boolean> {
|
async healthCheck(): Promise<boolean> {
|
||||||
if (!CONFIG_HYDRACKER.BASE_URL || !CONFIG_HYDRACKER.API_KEY) {
|
console.warn('[Hydracker] ⚠️ Plugin désactivé (Site fermé définitivement). Conservé pour archivage.');
|
||||||
console.warn('[Hydracker] ⚠️ HYDRACKER_URL ou HYDRACKER_API_KEY manquante.');
|
return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await fetch(CONFIG_HYDRACKER.BASE_URL, {
|
|
||||||
headers: { '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' },
|
|
||||||
signal: AbortSignal.timeout(CONFIG_HYDRACKER.TIMEOUT)
|
|
||||||
});
|
|
||||||
return res.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
|
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
|
||||||
@@ -41,12 +30,20 @@ export class HydrackerAPI implements ISource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||||
const type = mediaType === 'series' ? 'series' : 'movie';
|
// Channel 12 = Films, Channel 10 = Séries
|
||||||
|
const channelId = mediaType === 'series' ? 10 : 12;
|
||||||
try {
|
try {
|
||||||
const data = await apiGet('titles', { order: 'trending:desc', type, page: 1, paginate: 'lengthAware' });
|
const data = await apiGet(`channel/${channelId}`, {
|
||||||
|
restriction: '',
|
||||||
|
order: 'trending:desc',
|
||||||
|
filters: '',
|
||||||
|
page: 1,
|
||||||
|
paginate: 'lengthAware',
|
||||||
|
returnContentOnly: true
|
||||||
|
});
|
||||||
return parseTrendingResults(data);
|
return parseTrendingResults(data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(`[Hydracker] getTrending Error for ${type}:`, e.message);
|
console.error(`[Hydracker] getTrending Error for channel ${channelId}:`, e.message);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,16 +59,31 @@ export class HydrackerAPI implements ISource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||||
const seasonsList = await this.getSeasons(identifier);
|
// Récupérer les infos du titre via /download pour avoir les saisons
|
||||||
|
const titleData = await fetchDownloadPage(identifier);
|
||||||
|
|
||||||
let isSeries = false;
|
let isSeries = false;
|
||||||
if (type) {
|
if (type) {
|
||||||
isSeries = (type === 'series' || type === 'serie' || type === 'tv');
|
isSeries = (type === 'series' || type === 'serie' || type === 'tv');
|
||||||
} else {
|
} else if (titleData && titleData.title) {
|
||||||
isSeries = seasonsList.length > 0;
|
isSeries = titleData.title.is_series === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSeason = seasonValue ? parseInt(String(seasonValue), 10) : 1;
|
// 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 content = await this.getContentLinks(identifier, currentSeason);
|
||||||
const formattedSeasons = seasonsList.map(num => ({ label: `Saison ${num}`, value: num }));
|
const formattedSeasons = seasonsList.map(num => ({ label: `Saison ${num}`, value: num }));
|
||||||
|
|
||||||
@@ -83,36 +95,80 @@ export class HydrackerAPI implements ISource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getContentLinks(titleId: string, season: number = 1): Promise<ContentLinks> {
|
async getContentLinks(titleId: string, season: number = 1): Promise<ContentLinks> {
|
||||||
// Essai film en premier
|
if (season === 0) {
|
||||||
const movieData = await fetchMovieLinks(titleId);
|
// Film : utiliser /download directement
|
||||||
if (movieData) {
|
const downloadData = await fetchDownloadPage(titleId);
|
||||||
const movieLinks = parseMovieLinks(movieData);
|
if (!downloadData) return { links: [] };
|
||||||
if (movieLinks.length > 0) return { links: movieLinks };
|
return { links: this.parseLiensFromDownload(downloadData, season) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback série
|
// Série : itérer sur les épisodes
|
||||||
const rawLiens = await fetchSeriesLiens(titleId, season);
|
const rawLiens = await fetchSeriesLiens(titleId, season);
|
||||||
const links: VideoLink[] = rawLiens.map(l => ({
|
const links: VideoLink[] = rawLiens.map(l => this.parseSingleLien(l, season));
|
||||||
id: l.id,
|
|
||||||
host: (l.host && l.host.name) || '?',
|
|
||||||
size: formatSize(l.taille),
|
|
||||||
sizeBytes: l.taille || 0,
|
|
||||||
quality: QUALITY_MAP[l.qualite] || `id:${l.qualite}`,
|
|
||||||
langs: getLangs(l),
|
|
||||||
subs: getSubs(l),
|
|
||||||
releaseName: l.release || l.name || l.titre || l.titre_release || undefined,
|
|
||||||
episode: (l.episode === 0 || l.episode === "0" || l.episode === "00")
|
|
||||||
? 'Saison complète'
|
|
||||||
: (l.episode ? String(l.episode) : null),
|
|
||||||
url: null
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { links };
|
return { links };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse les liens depuis une réponse /download (film ou épisode unique)
|
||||||
|
*/
|
||||||
|
private parseLiensFromDownload(downloadData: any, season: number): VideoLink[] {
|
||||||
|
const allLiens: any[] = [];
|
||||||
|
if (downloadData.video) allLiens.push(downloadData.video);
|
||||||
|
if (downloadData.alternative_videos) {
|
||||||
|
for (const av of downloadData.alternative_videos) {
|
||||||
|
if (!allLiens.find(l => l.id === av.id)) {
|
||||||
|
allLiens.push(av);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allLiens.map(l => this.parseSingleLien(l, season));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit un objet lien brut de l'API en VideoLink unifié
|
||||||
|
*/
|
||||||
|
private parseSingleLien(l: any, season: number): VideoLink {
|
||||||
|
// Extraire le nom du host
|
||||||
|
const hostName = l.host_compact?.name || l.host?.name || l.name || '?';
|
||||||
|
|
||||||
|
// Extraire la qualité
|
||||||
|
const quality = l.qual?.qual || l.quality || QUALITY_MAP[l.qualite] || `id:${l.qualite}`;
|
||||||
|
|
||||||
|
// Extraire les langues
|
||||||
|
const langs = l.langues
|
||||||
|
? l.langues.map((la: any) => la.lang || la.name || '')
|
||||||
|
: getLangs(l);
|
||||||
|
|
||||||
|
// Extraire les sous-titres
|
||||||
|
const subs = l.subs_compact
|
||||||
|
? l.subs_compact.map((s: any) => s.name || '')
|
||||||
|
: getSubs(l);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: l.id,
|
||||||
|
host: hostName,
|
||||||
|
size: formatSize(l.taille),
|
||||||
|
sizeBytes: l.taille || 0,
|
||||||
|
quality,
|
||||||
|
langs,
|
||||||
|
subs,
|
||||||
|
releaseName: l.release || l.filename || l.name || l.titre || l.titre_release || undefined,
|
||||||
|
episode: (l.episode === 0 || l.episode === "0" || l.episode === "00" || l.episode === null)
|
||||||
|
? (season === 0 ? 'Film complet' : 'Saison complète')
|
||||||
|
: (l.episode ? String(l.episode) : null),
|
||||||
|
url: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getSeasons(titleId: string): Promise<number[]> {
|
async getSeasons(titleId: string): Promise<number[]> {
|
||||||
const result = await apiGet(`titles/${titleId}/seasons`);
|
// Utiliser /download pour récupérer les saisons (au lieu de /titles/{id} qui est redondant)
|
||||||
return parseSeasons(result);
|
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 isPremiumCache: boolean | null = null;
|
||||||
@@ -150,50 +206,54 @@ export class HydrackerAPI implements ISource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPremium = await this.checkPremiumStatus();
|
// Tenter la résolution via l'API /content/liens/{id}
|
||||||
|
try {
|
||||||
if (!isPremium) {
|
const result = await apiGet(`content/liens/${linkId}`);
|
||||||
console.log(`[Hydracker] Compte non Premium détecté. Bypass de Hydracker, passage direct à Movix...`);
|
if (result && (result.directDL || result.url || result.link)) {
|
||||||
return await this.resolveMovixLink(linkId);
|
const finalUrl = result.directDL || result.url || result.link;
|
||||||
}
|
console.log(`[Hydracker] Got final URL via API: ${finalUrl.substring(0, 80)}...`);
|
||||||
|
|
||||||
const maxRetries = 4;
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
if (attempt > 1) {
|
|
||||||
console.log(`[Hydracker] Retry ${attempt}/${maxRetries} for lien ${linkId}`);
|
|
||||||
await new Promise(r => setTimeout(r, 4000));
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await apiGet(`content/liens/${linkId}`);
|
|
||||||
if (!result) continue;
|
|
||||||
|
|
||||||
const finalUrl = result.directDL || result.url || result.link || '';
|
|
||||||
if (!finalUrl) continue;
|
|
||||||
|
|
||||||
console.log(`[Hydracker] Got final URL: ${finalUrl.substring(0, 80)}...`);
|
|
||||||
|
|
||||||
return finalUrl;
|
return finalUrl;
|
||||||
} catch (e: any) {
|
|
||||||
console.error(`[Hydracker] Exception resolving lien ${linkId} (attempt ${attempt}):`, e.message);
|
|
||||||
}
|
}
|
||||||
|
// 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 classique (Erreur). Fallback automatique via Movix...`);
|
console.log(`[Hydracker] Échec de la résolution API. Fallback automatique via Movix...`);
|
||||||
return await this.resolveMovixLink(linkId);
|
return await this.resolveMovixLink(linkId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveMovixLink(lienId: string, titleId?: string): Promise<string | null> {
|
async resolveMovixLink(lienId: string, titleId?: string): Promise<string | null> {
|
||||||
try {
|
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}...`);
|
console.log(`[Hydracker] Tentative de débridage Movix pour le lien ${lienId}...`);
|
||||||
const url = `https://api.movix.cloud/api/darkiworld/decode/${lienId}${titleId ? `?title_id=${titleId}` : ''}`;
|
const url = `${movixApiBase}/darkiworld/decode/${lienId}${titleId ? `?title_id=${titleId}` : ''}`;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Referer': 'https://movix.cloud/',
|
'Accept': 'application/json, text/plain, */*',
|
||||||
'Origin': 'https://movix.cloud',
|
'Referer': `${movixBase}/`,
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
'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'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,7 +264,6 @@ export class HydrackerAPI implements ISource {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupération du lien direct selon le format de réponse Movix
|
|
||||||
const directUrl = data.directDL || data.direct_url ||
|
const directUrl = data.directDL || data.direct_url ||
|
||||||
(data.embed_url && (data.embed_url.directDL || data.embed_url.src || data.embed_url.lien));
|
(data.embed_url && (data.embed_url.directDL || data.embed_url.src || data.embed_url.lien));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { ISource, SearchResult, SelectionData, ContentLinks, MediaType } from '../../src/types/source.js';
|
||||||
|
import { CONFIG } from '../../src/utils/config.js';
|
||||||
|
|
||||||
|
export class LoadixSource implements ISource {
|
||||||
|
name = 'loadix';
|
||||||
|
displayName = 'Loadix';
|
||||||
|
|
||||||
|
private get frontUrl() { return (CONFIG.LOADIX_URL || '').replace(/\/+$/, ''); }
|
||||||
|
private get baseUrl() {
|
||||||
|
const urlObj = new URL(this.frontUrl);
|
||||||
|
return `https://api.${urlObj.host}/api`;
|
||||||
|
}
|
||||||
|
private tmdbImageBase = 'https://image.tmdb.org/t/p/w500';
|
||||||
|
|
||||||
|
private mapType(type: string): MediaType {
|
||||||
|
if (type === 'series') return 'series';
|
||||||
|
if (type === 'anime') return 'anime';
|
||||||
|
return 'movie';
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatSearchResult(hit: any): SearchResult {
|
||||||
|
return {
|
||||||
|
title: hit.title,
|
||||||
|
year: hit.year ? hit.year.toString() : null,
|
||||||
|
image: hit.posterPath ? `${this.tmdbImageBase}${hit.posterPath}` : null,
|
||||||
|
hrefPath: `${this.frontUrl}/media/${hit.id}`,
|
||||||
|
type: this.mapType(hit.type),
|
||||||
|
source: this.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
|
||||||
|
const url = `${this.baseUrl}/media/search?q=${encodeURIComponent(query)}&page=1&pageSize=30`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
let hits = data.hits || [];
|
||||||
|
if (mediaType && mediaType !== 'other') {
|
||||||
|
hits = hits.filter((h: any) => this.mapType(h.type) === mediaType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits.map((h: any) => this.formatSearchResult(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||||
|
const url = `${this.baseUrl}/media/search?q=&page=1&pageSize=30&sort=click_count_desc`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
let hits = data.hits || [];
|
||||||
|
if (mediaType && mediaType !== 'other') {
|
||||||
|
hits = hits.filter((h: any) => this.mapType(h.type) === mediaType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits.map((h: any) => this.formatSearchResult(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecent(): Promise<SearchResult[]> {
|
||||||
|
const url = `${this.baseUrl}/media/recent?limit=24`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const items = data.items || [];
|
||||||
|
return items.map((h: any) => this.formatSearchResult(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||||
|
const idMatch = identifier.match(/media\/([a-f0-9\-]+)/);
|
||||||
|
if (!idMatch) throw new Error("URL Loadix invalide.");
|
||||||
|
const mediaId = idMatch[1];
|
||||||
|
|
||||||
|
// Fetch links
|
||||||
|
const url = `${this.baseUrl}/media/${mediaId}/links?page=1&perPage=100&sort=scope_asc`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
|
||||||
|
const links = items.map((item: any) => {
|
||||||
|
let episode = null;
|
||||||
|
if (item.scope === 'season' && item.seasonNumber) {
|
||||||
|
episode = `S${String(item.seasonNumber).padStart(2, '0')}`;
|
||||||
|
} else if (item.scope === 'episode' && item.seasonNumber && item.episodeNumber) {
|
||||||
|
episode = `S${String(item.seasonNumber).padStart(2, '0')}E${String(item.episodeNumber).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${identifier}|${item.id}`,
|
||||||
|
host: item.provider || 'unknown',
|
||||||
|
quality: item.quality,
|
||||||
|
langs: item.language ? [item.language] : [],
|
||||||
|
sizeBytes: item.sizeBytes ? parseInt(item.sizeBytes) : undefined,
|
||||||
|
size: item.sizeHuman,
|
||||||
|
releaseName: item.releaseGroup,
|
||||||
|
episode: episode,
|
||||||
|
url: null // Protected by Turnstile, resolved later by direct redirect
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if there are any episodes/seasons to determine if it's a series
|
||||||
|
const isSeries = links.some((l: any) => l.episode);
|
||||||
|
|
||||||
|
// Extract seasons (just based on found links)
|
||||||
|
const seasonsMap = new Map<string, string>();
|
||||||
|
if (isSeries) {
|
||||||
|
items.forEach((item: any) => {
|
||||||
|
if (item.seasonNumber) {
|
||||||
|
const seasonStr = `Saison ${item.seasonNumber}`;
|
||||||
|
seasonsMap.set(String(item.seasonNumber), seasonStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasons = Array.from(seasonsMap.entries()).map(([val, label]) => ({
|
||||||
|
value: val,
|
||||||
|
label: label
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
links,
|
||||||
|
seasons,
|
||||||
|
isSeries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
|
||||||
|
const selection = await this.getSelection(identifier);
|
||||||
|
let links = selection.links;
|
||||||
|
|
||||||
|
if (season) {
|
||||||
|
const seasonPrefix = `S${String(season).padStart(2, '0')}`;
|
||||||
|
links = links.filter(l => l.episode && l.episode.startsWith(seasonPrefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { links };
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
if (!this.frontUrl) {
|
||||||
|
console.warn('[Loadix] ⚠️ LOADIX_URL non définie.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const results = await this.getRecent();
|
||||||
|
return results.length > 0;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(`[Loadix] Healthcheck failed: ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveLink(linkId: string, extraData?: any): Promise<any> {
|
||||||
|
const [url] = linkId.split('|');
|
||||||
|
// Like Flixart, Turnstile cannot be solved on localhost.
|
||||||
|
// We directly return the manual redirection challenge to open Loadix.
|
||||||
|
return {
|
||||||
|
captcha: 'turnstile',
|
||||||
|
url: url,
|
||||||
|
sourceName: 'Loadix'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-registration
|
||||||
|
import { sourceRegistry } from '../../src/core/registry.js';
|
||||||
|
sourceRegistry.register(new LoadixSource());
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { CONFIG } from '../../src/utils/config.js';
|
||||||
|
|
||||||
|
export class MovixAPI {
|
||||||
|
private static get baseUrl(): string {
|
||||||
|
return CONFIG.MOVIX_URL || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static get apiUrl(): string {
|
||||||
|
if (!this.baseUrl) return '';
|
||||||
|
try {
|
||||||
|
const url = new URL(this.baseUrl);
|
||||||
|
return `${url.protocol}//api.${url.host}/api`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getHeaders() {
|
||||||
|
return {
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Origin': this.baseUrl,
|
||||||
|
'Referer': `${this.baseUrl}/`,
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 OPR/133.0.0.0'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async search(query: string): Promise<any> {
|
||||||
|
const url = `${this.apiUrl}/search?title=${encodeURIComponent(query)}`;
|
||||||
|
const res = await fetch(url, { headers: this.getHeaders() });
|
||||||
|
if (!res.ok) throw new Error(`Movix Search HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getDownloadLinks(type: string, id: number | string, tmdbId: number | string): Promise<any> {
|
||||||
|
const url = `${this.apiUrl}/darkiworld/download/${type}/${id}?tmdbId=${tmdbId}`;
|
||||||
|
const res = await fetch(url, { headers: this.getHeaders() });
|
||||||
|
if (!res.ok) throw new Error(`Movix Download HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async decodeLink(linkId: string | number, titleId: string | number): Promise<any> {
|
||||||
|
const url = `${this.apiUrl}/darkiworld/decode/${linkId}?title_id=${titleId}`;
|
||||||
|
const res = await fetch(url, { headers: this.getHeaders() });
|
||||||
|
if (!res.ok) throw new Error(`Movix Decode HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { ISource, SearchResult, SelectionData, ContentLinks, MediaType, VideoLink } from '../../src/types/source.js';
|
||||||
|
import { sourceRegistry } from '../../src/core/registry.js';
|
||||||
|
import { MovixAPI } from './api.js';
|
||||||
|
import { CONFIG } from '../../src/utils/config.js';
|
||||||
|
|
||||||
|
class MovixSource implements ISource {
|
||||||
|
public readonly name = 'movix';
|
||||||
|
public readonly displayName = 'Movix';
|
||||||
|
|
||||||
|
public async healthCheck(): Promise<boolean> {
|
||||||
|
if (!CONFIG.MOVIX_URL) return false;
|
||||||
|
try {
|
||||||
|
// A quick check to see if the search endpoint is reachable
|
||||||
|
await MovixAPI.search('test');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[MOVIX] Health check failed:`, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
|
||||||
|
try {
|
||||||
|
const response = await MovixAPI.search(query);
|
||||||
|
if (!response || !response.results) return [];
|
||||||
|
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
const searchLower = query.toLowerCase().trim();
|
||||||
|
|
||||||
|
for (const item of response.results) {
|
||||||
|
if (!item.name) continue;
|
||||||
|
|
||||||
|
// Filtre optionnel pour aligner les résultats avec la recherche
|
||||||
|
const nameLower = item.name.toLowerCase();
|
||||||
|
const originalLower = item.original_title ? item.original_title.toLowerCase() : '';
|
||||||
|
|
||||||
|
// On vérifie si la requête est incluse dans le titre ou le titre original
|
||||||
|
if (!nameLower.includes(searchLower) && !originalLower.includes(searchLower)) {
|
||||||
|
// Pour être un peu plus permissif, on vérifie si tous les mots clés y sont
|
||||||
|
const words = searchLower.split(' ');
|
||||||
|
const allWordsMatch = words.every(w => nameLower.includes(w) || originalLower.includes(w));
|
||||||
|
if (!allWordsMatch) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrage basique par mediaType si fourni
|
||||||
|
if (mediaType) {
|
||||||
|
if (mediaType === 'movie' && item.type !== 'movie') continue;
|
||||||
|
if (mediaType === 'series' && item.type !== 'serie') continue; // Verify if it's 'serie' or 'series'
|
||||||
|
}
|
||||||
|
|
||||||
|
const hrefPath = `movix:${item.id}:${item.tmdb_id || 0}:${item.type}`;
|
||||||
|
|
||||||
|
let image = null;
|
||||||
|
if (item.poster) {
|
||||||
|
image = item.poster.startsWith('http') ? item.poster : `https://image.tmdb.org/t/p/w300/${item.poster}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
title: item.name,
|
||||||
|
year: item.year ? item.year.toString() : null,
|
||||||
|
image,
|
||||||
|
hrefPath,
|
||||||
|
type: item.type === 'movie' ? 'movie' : (item.type === 'serie' || item.type === 'series' ? 'series' : 'other'),
|
||||||
|
source: this.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[MOVIX] Search error:`, e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||||
|
const typeStr = mediaType === 'series' ? 'tv' : 'movie';
|
||||||
|
const url = `https://api.themoviedb.org/3/trending/${typeStr}/day?api_key=f3d757824f08ea2cff45eb8f47ca3a1e&language=fr-FR`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data || !data.results) return [];
|
||||||
|
|
||||||
|
return data.results.map((item: any) => {
|
||||||
|
const title = item.title || item.name;
|
||||||
|
const year = item.release_date ? item.release_date.split('-')[0] : (item.first_air_date ? item.first_air_date.split('-')[0] : null);
|
||||||
|
const image = item.poster_path ? `https://image.tmdb.org/t/p/w300${item.poster_path}` : null;
|
||||||
|
const tmdbId = item.id;
|
||||||
|
const type = mediaType === 'series' ? 'series' : 'movie';
|
||||||
|
|
||||||
|
// Identifiant spécial pour faire la recherche au moment du clic
|
||||||
|
const hrefPath = `movix:tmdb:${tmdbId}:${type}:${encodeURIComponent(title)}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
image,
|
||||||
|
hrefPath,
|
||||||
|
type,
|
||||||
|
source: this.name
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
console.error(`[MOVIX] TMDB Trending error:`, e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRecent(): Promise<SearchResult[]> {
|
||||||
|
// Fallback on movies trending as recent if no specific endpoint
|
||||||
|
return this.getTrending('movie');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
|
||||||
|
try {
|
||||||
|
const parts = identifier.split(':');
|
||||||
|
if (parts.length < 4) return { links: [] };
|
||||||
|
|
||||||
|
let id: string, tmdbId: string, type: string;
|
||||||
|
|
||||||
|
if (parts[1] === 'tmdb') {
|
||||||
|
tmdbId = parts[2];
|
||||||
|
type = parts[3];
|
||||||
|
const title = decodeURIComponent(parts.slice(4).join(':'));
|
||||||
|
|
||||||
|
// Recherche sur Movix pour récupérer l'ID interne
|
||||||
|
const searchRes = await MovixAPI.search(title);
|
||||||
|
const item = searchRes.results?.find((r: any) => String(r.tmdb_id) === String(tmdbId) || r.name === title);
|
||||||
|
if (!item) {
|
||||||
|
console.log(`[MOVIX] TMDB item not found on Movix: ${title}`);
|
||||||
|
return { links: [] };
|
||||||
|
}
|
||||||
|
id = item.id;
|
||||||
|
// Update type depending on what Movix returned
|
||||||
|
type = item.type === 'serie' || item.type === 'series' ? 'series' : 'movie';
|
||||||
|
} else {
|
||||||
|
id = parts[1];
|
||||||
|
tmdbId = parts[2];
|
||||||
|
type = parts[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await MovixAPI.getDownloadLinks(type, id, tmdbId);
|
||||||
|
|
||||||
|
const links: VideoLink[] = [];
|
||||||
|
|
||||||
|
if (data && data.data) {
|
||||||
|
// Pour chaque host (1fichier, etc)
|
||||||
|
for (const item of data.data) {
|
||||||
|
if (!item.links || !Array.isArray(item.links)) continue;
|
||||||
|
|
||||||
|
const host = item.host || 'unknown';
|
||||||
|
const quality = item.qualite || 'Unknown';
|
||||||
|
const lang = item.langue || 'Unknown';
|
||||||
|
const size = item.size || '';
|
||||||
|
|
||||||
|
for (const linkObj of item.links) {
|
||||||
|
const linkId = linkObj.id;
|
||||||
|
if (!linkId) continue;
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
id: `${linkId}|${id}`, // Store both linkId and titleId
|
||||||
|
host: host,
|
||||||
|
label: `${quality} - ${lang}`,
|
||||||
|
url: null, // Resolves later
|
||||||
|
size: size,
|
||||||
|
quality: quality,
|
||||||
|
langs: [lang]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { links };
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[MOVIX] Error in getContentLinks:`, e);
|
||||||
|
return { links: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||||
|
const content = await this.getContentLinks(identifier);
|
||||||
|
return {
|
||||||
|
links: content.links,
|
||||||
|
seasons: [],
|
||||||
|
isSeries: type === 'series'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolveLink(combinedId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const [linkId, titleId] = combinedId.split('|');
|
||||||
|
if (!linkId || !titleId) return null;
|
||||||
|
|
||||||
|
const res = await MovixAPI.decodeLink(linkId, titleId);
|
||||||
|
if (res && res.url) {
|
||||||
|
return res.url;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[MOVIX] ResolveLink error for ${combinedId}:`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceRegistry.register(new MovixSource());
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Appels réseau pour zt.news.
|
* Appels réseau pour zone-telechargement.news.
|
||||||
* Pas de challenge CF actif, fetch direct simple.
|
* Pas de challenge CF actif, fetch direct simple.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,10 @@ function deduplicateByTitle(results: SearchResult[]): SearchResult[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ZtTeamAPI implements ISource {
|
export class ZtTeamAPI implements ISource {
|
||||||
name = 'ztteam';
|
name = 'ztnews';
|
||||||
displayName = 'ZT (Team)';
|
displayName = 'Zone-Téléchargement (Team)';
|
||||||
private baseUrl: string | undefined;
|
get baseUrl() {
|
||||||
|
return CONFIG.ZTTEAM_URL?.replace(/\/$/, '');
|
||||||
constructor(baseUrl?: string) {
|
|
||||||
this.baseUrl = baseUrl?.replace(/\/$/, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async healthCheck(): Promise<boolean> {
|
async healthCheck(): Promise<boolean> {
|
||||||
@@ -132,4 +130,4 @@ export class ZtTeamAPI implements ISource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceRegistry.register(new ZtTeamAPI(CONFIG.ZTTEAM_URL));
|
sourceRegistry.register(new ZtTeamAPI());
|
||||||
|
|||||||
@@ -69,9 +69,15 @@ export function parseListingHTML(html: string, baseUrl: string): SearchResult[]
|
|||||||
const title = titleMatch[2]!.trim();
|
const title = titleMatch[2]!.trim();
|
||||||
const imgMatch = block.match(/<img class="mainimg"[^>]*src="([^"]+)"/);
|
const imgMatch = block.match(/<img class="mainimg"[^>]*src="([^"]+)"/);
|
||||||
const image = imgMatch ? absUrl(imgMatch[1]!, baseUrl) : null;
|
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({
|
results.push({
|
||||||
title,
|
title,
|
||||||
year: null,
|
year,
|
||||||
image,
|
image,
|
||||||
hrefPath: href,
|
hrefPath: href,
|
||||||
type: detectType(titleMatch[1]!),
|
type: detectType(titleMatch[1]!),
|
||||||
|
|||||||
+113
-7
@@ -366,6 +366,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.querySelectorAll('input[name="trending-type"]').forEach(radio => {
|
document.querySelectorAll('input[name="trending-type"]').forEach(radio => {
|
||||||
radio.addEventListener('change', renderTrending);
|
radio.addEventListener('change', renderTrending);
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('input[name="recent-type"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', renderRecent);
|
||||||
|
});
|
||||||
|
|
||||||
// --- HEARTBEAT : Détection source down + Refresh Tendances ---
|
// --- HEARTBEAT : Détection source down + Refresh Tendances ---
|
||||||
let wasOffline = false;
|
let wasOffline = false;
|
||||||
@@ -840,6 +843,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (!movie.quality) movie.quality = qualityFromTitle;
|
if (!movie.quality) movie.quality = qualityFromTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (movie.year) parts.push(movie.year);
|
||||||
if (movie.quality) parts.push(movie.quality);
|
if (movie.quality) parts.push(movie.quality);
|
||||||
if (movie.lang) parts.push(movie.lang);
|
if (movie.lang) parts.push(movie.lang);
|
||||||
subtitle = parts.join(' — ') || '';
|
subtitle = parts.join(' — ') || '';
|
||||||
@@ -856,10 +860,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-info">
|
<div class="card-info">
|
||||||
<div class="card-title">${cleanedTitle}</div>
|
<div class="card-title">${cleanedTitle}</div>
|
||||||
<div class="card-year">${typeBadge} ${subtitle}</div>
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div class="card-year">${typeBadge} ${subtitle}</div>
|
||||||
|
<a href="https://www.themoviedb.org/search?query=${encodeURIComponent(cleanedTitle)}" target="_blank" onclick="event.stopPropagation()" class="tmdb-link" title="Rechercher sur TMDB" style="color: var(--primary); opacity: 0.7; transition: opacity 0.2s;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 3v18"/><path d="M3 7.5h4"/><path d="M3 12h18"/><path d="M3 16.5h4"/><path d="M17 3v18"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
div.addEventListener('click', () => handleSelection(movie));
|
div.addEventListener('click', () => handleSelection(movie));
|
||||||
|
|
||||||
|
// Add hover effect for the TMDB link
|
||||||
|
const tmdbLink = div.querySelector('.tmdb-link');
|
||||||
|
if (tmdbLink) {
|
||||||
|
tmdbLink.addEventListener('mouseenter', () => tmdbLink.style.opacity = '1');
|
||||||
|
tmdbLink.addEventListener('mouseleave', () => tmdbLink.style.opacity = '0.7');
|
||||||
|
}
|
||||||
return div;
|
return div;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -900,12 +916,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const grid = dom('recent-grid');
|
const grid = dom('recent-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
const itemsToDisplay = state.trendingData.recent || [];
|
// Get filter type from radio buttons
|
||||||
|
const typeFilter = document.querySelector('input[name="recent-type"]:checked')?.value || 'all';
|
||||||
|
|
||||||
|
let itemsToDisplay = state.trendingData.recent || [];
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
if (typeFilter === 'film') {
|
||||||
|
itemsToDisplay = itemsToDisplay.filter(m => m.type === 'movie');
|
||||||
|
} else if (typeFilter === 'serie') {
|
||||||
|
itemsToDisplay = itemsToDisplay.filter(m => m.type === 'series' || m.type === 'serie');
|
||||||
|
}
|
||||||
|
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
|
|
||||||
if (!itemsToDisplay || !itemsToDisplay.length) {
|
if (!itemsToDisplay || !itemsToDisplay.length) {
|
||||||
grid.innerHTML = '<p style="padding:1rem; opacity:0.7;">Aucun ajout récent trouvé.</p>';
|
grid.innerHTML = '<p style="padding:1rem; opacity:0.7;">Aucun ajout récent trouvé pour ce filtre.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,6 +988,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
radio.addEventListener('change', renderTrending);
|
radio.addEventListener('change', renderTrending);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="recent-type"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', renderRecent);
|
||||||
|
});
|
||||||
|
|
||||||
// --- SEARCH ---
|
// --- SEARCH ---
|
||||||
const searchInput = dom('search-input');
|
const searchInput = dom('search-input');
|
||||||
const searchBtn = dom('btn-search-trigger');
|
const searchBtn = dom('btn-search-trigger');
|
||||||
@@ -1160,7 +1190,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILM_SIZE_MB = 45360;
|
const MAX_FILM_SIZE_MB = 150000;
|
||||||
|
|
||||||
const enriched = data.clientOptions.map(q => {
|
const enriched = data.clientOptions.map(q => {
|
||||||
const lowEp = q.episode ? q.episode.toLowerCase() : '';
|
const lowEp = q.episode ? q.episode.toLowerCase() : '';
|
||||||
@@ -1466,7 +1496,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
toggleBlockingLoader(true, "Récupération du lien...");
|
toggleBlockingLoader(true, "Récupération du lien...");
|
||||||
try {
|
try {
|
||||||
const result = await apiCall('/get-link', 'POST', { chosenId: q.id, useJD });
|
let result = await apiCall('/get-link', 'POST', { chosenId: q.id, useJD });
|
||||||
|
|
||||||
|
if (result.status === 'challenge' && result.challenge) {
|
||||||
|
toggleBlockingLoader(false);
|
||||||
|
const captchaData = await window.handleCaptchaChallenge(result.challenge);
|
||||||
|
toggleBlockingLoader(true, "Vérification du captcha...");
|
||||||
|
result = await apiCall('/get-link', 'POST', { chosenId: q.id, useJD, captchaData });
|
||||||
|
}
|
||||||
|
|
||||||
toggleBlockingLoader(false);
|
toggleBlockingLoader(false);
|
||||||
|
|
||||||
if (useJD) {
|
if (useJD) {
|
||||||
@@ -1555,11 +1593,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (forcedDirect) {
|
if (forcedDirect) {
|
||||||
useJD = false;
|
useJD = false;
|
||||||
}
|
}
|
||||||
toggleBlockingLoader(true, "Récupération des liens...");
|
|
||||||
try {
|
try {
|
||||||
const result = await apiCall('/get-links-batch', 'POST', { chosenIds: Array.from(selectedIds), useJD });
|
toggleBlockingLoader(true, "Récupération des liens...");
|
||||||
|
let result = await apiCall('/get-links-batch', 'POST', { chosenIds: Array.from(selectedIds), useJD });
|
||||||
|
|
||||||
|
if (result.status === 'challenge' && result.challenge) {
|
||||||
|
toggleBlockingLoader(false);
|
||||||
|
const captchaData = await window.handleCaptchaChallenge(result.challenge);
|
||||||
|
toggleBlockingLoader(true, "Vérification du captcha et récupération...");
|
||||||
|
result = await apiCall('/get-links-batch', 'POST', { chosenIds: Array.from(selectedIds), useJD, captchaData });
|
||||||
|
}
|
||||||
|
|
||||||
toggleBlockingLoader(false);
|
toggleBlockingLoader(false);
|
||||||
|
|
||||||
|
if (result.errors) {
|
||||||
|
showToast('Erreurs: ' + result.errors.join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
if (useJD) {
|
if (useJD) {
|
||||||
showToast(result.message || 'Liens envoyés à JDownloader !');
|
showToast(result.message || 'Liens envoyés à JDownloader !');
|
||||||
} else {
|
} else {
|
||||||
@@ -1601,6 +1651,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
dom('modal-body').innerHTML = content;
|
dom('modal-body').innerHTML = content;
|
||||||
show(dom('modal-overlay'));
|
show(dom('modal-overlay'));
|
||||||
};
|
};
|
||||||
|
window.showModal = showModal;
|
||||||
|
|
||||||
const showDirectLinkModal = (title, content) => {
|
const showDirectLinkModal = (title, content) => {
|
||||||
const existing = document.getElementById('direct-link-modal-overlay');
|
const existing = document.getElementById('direct-link-modal-overlay');
|
||||||
@@ -1649,6 +1700,61 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.handleCaptchaChallenge = function(challenge) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const existing = document.getElementById('captcha-modal-overlay');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'captcha-modal-overlay';
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.style.zIndex = '10005';
|
||||||
|
|
||||||
|
if (challenge.captcha === 'turnstile') {
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal-content" style="max-width: 450px; text-align: center;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Protection Cloudflare</h3>
|
||||||
|
<button id="captcha-modal-close"><i data-lucide="x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="margin-bottom: 15px; color: var(--text-sec);">Ce lien est protégé par Cloudflare. Vous devez le résoudre manuellement sur le site source.</p>
|
||||||
|
<p style="margin-bottom: 20px; color: var(--text-sec); font-size: 0.9em;">Une fois sur ${challenge.sourceName || 'le site'}, cliquez sur le lien, résolvez le captcha, puis copiez le lien 1Fichier obtenu pour l'ajouter dans l'onglet "Ajout Manuel" d'Agora.</p>
|
||||||
|
<a href="${challenge.url}" target="_blank" class="btn-action" style="display: inline-flex; align-items: center; gap: 8px; background: var(--primary); color: var(--bg); padding: 12px 20px; border-radius: 8px; text-decoration: none; font-weight: 600;">
|
||||||
|
<i data-lucide="external-link" style="width: 18px; height: 18px;"></i> Ouvrir ${challenge.sourceName || 'le site'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal-content" style="max-width: 400px; text-align: center;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Vérification requise</h3>
|
||||||
|
<button id="captcha-modal-close"><i data-lucide="x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="margin-bottom: 15px; color: var(--text-sec);">Captcha non supporté (${challenge.captcha}).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
lucide.createIcons({ root: overlay });
|
||||||
|
show(overlay);
|
||||||
|
|
||||||
|
const closeBtn = overlay.querySelector('#captcha-modal-close');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
reject(new Error(challenge.captcha === 'turnstile' ? 'Redirection manuelle requise par Cloudflare.' : 'Captcha annulé.'));
|
||||||
|
hide(overlay);
|
||||||
|
setTimeout(() => overlay.remove(), 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const modalClose = dom('modal-close');
|
const modalClose = dom('modal-close');
|
||||||
if (modalClose) {
|
if (modalClose) {
|
||||||
modalClose.onclick = () => {
|
modalClose.onclick = () => {
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="334px" height="292px" viewBox="0 0 334 292" enable-background="new 0 0 334 292" xml:space="preserve"> <image id="image0" width="334" height="292" x="0" y="0"
|
||||||
|
xlink:href="data:image/png;base64,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" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,306 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="334px" height="292px" viewBox="0 0 334 292" enable-background="new 0 0 334 292" xml:space="preserve"> <image id="image0" width="334" height="292" x="0" y="0"
|
||||||
|
xlink:href="data:image/png;base64,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" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 23 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Hydr'Hacked",
|
"name": "Agora",
|
||||||
"short_name": "Hydr'Hacked",
|
"short_name": "Agora",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
|
|||||||
Vendored
+2
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'hydrhacked-v4.8';
|
const CACHE_NAME = 'Agora-v1.5.2';
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
'/style.css',
|
'/style.css',
|
||||||
'/app.auth.js',
|
'/app.auth.js',
|
||||||
@@ -45,7 +45,7 @@ self.addEventListener('fetch', event => {
|
|||||||
url.pathname.endsWith('.png') ||
|
url.pathname.endsWith('.png') ||
|
||||||
url.pathname.endsWith('.json');
|
url.pathname.endsWith('.json');
|
||||||
|
|
||||||
if (!isStaticAsset || event.request.method !== 'GET') {
|
if (!isStaticAsset || event.request.method !== 'GET' || !url.protocol.startsWith('http')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,14 +27,18 @@ class SourceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lance les health checks sur toutes les sources en attente (en parallèle)
|
* Lance les health checks sur toutes les sources enregistrées (en parallèle)
|
||||||
* Seules les sources qui passent le check sont dites"active"
|
* Seules les sources qui passent le check sont dites"active"
|
||||||
*/
|
*/
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
console.log(`[Registry] ${this.pending.length} source(s) détectée(s), lancement des health checks...`);
|
const sourcesToTest = Array.from(this.allRegistered.values());
|
||||||
|
console.log(`[Registry] Lancement des health checks sur ${sourcesToTest.length} source(s)...`);
|
||||||
|
|
||||||
|
this.active.clear();
|
||||||
|
this.pending = [];
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
this.pending.map(async (source) => {
|
sourcesToTest.map(async (source) => {
|
||||||
const healthy = await source.healthCheck();
|
const healthy = await source.healthCheck();
|
||||||
return { source, healthy };
|
return { source, healthy };
|
||||||
})
|
})
|
||||||
@@ -55,7 +59,6 @@ class SourceRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pending = [];
|
|
||||||
const names = this.getAvailableNames();
|
const names = this.getAvailableNames();
|
||||||
console.log(`[Registry] ${this.active.size} source(s) active(s): ${names.length ? names.map(n => n.toUpperCase()).join(', ') : 'Aucune'}`);
|
console.log(`[Registry] ${this.active.size} source(s) active(s): ${names.length ? names.map(n => n.toUpperCase()).join(', ') : 'Aucune'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-7
@@ -1,3 +1,4 @@
|
|||||||
|
import './utils/logger.js';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import connectSessionFileStore from 'session-file-store';
|
import connectSessionFileStore from 'session-file-store';
|
||||||
@@ -31,6 +32,11 @@ const PORT = CONFIG.PORT;
|
|||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
app.set('views', path.join(process.cwd(), 'views'));
|
app.set('views', path.join(process.cwd(), 'views'));
|
||||||
|
|
||||||
|
// Inject appVersion in all views
|
||||||
|
import fs from 'fs';
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'));
|
||||||
|
app.locals.appVersion = packageJson.version;
|
||||||
|
|
||||||
// ========================= MIDDLEWARES SÉCURITÉ =========================
|
// ========================= MIDDLEWARES SÉCURITÉ =========================
|
||||||
|
|
||||||
// CORS : désactivé (app self-hosted, pas besoin de cross-origin)
|
// CORS : désactivé (app self-hosted, pas besoin de cross-origin)
|
||||||
@@ -39,12 +45,13 @@ app.set('views', path.join(process.cwd(), 'views'));
|
|||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
"default-src": ["'self'"],
|
||||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
"script-src": ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
"script-src-attr": ["'unsafe-inline'"],
|
||||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||||
imgSrc: ["'self'", "data:", "blob:"],
|
"font-src": ["'self'", "https://fonts.gstatic.com"],
|
||||||
connectSrc: ["'self'"],
|
"img-src": ["'self'", "data:", "blob:", "https://www.google.com", "https://*.gstatic.com"],
|
||||||
|
"connect-src": ["'self'"],
|
||||||
"upgrade-insecure-requests": null,
|
"upgrade-insecure-requests": null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -98,7 +105,7 @@ app.use(express.static(path.join(process.cwd(), 'public')));
|
|||||||
|
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
console.log(`\n${'='.repeat(60)}`);
|
console.log(`\n${'='.repeat(60)}`);
|
||||||
console.log(` Hydr'Hacked — API Server`);
|
console.log(` Agora — API Server`);
|
||||||
console.log(`${'='.repeat(60)}`);
|
console.log(`${'='.repeat(60)}`);
|
||||||
console.log(`Serveur API démarré sur http://localhost:${PORT}\n`);
|
console.log(`Serveur API démarré sur http://localhost:${PORT}\n`);
|
||||||
|
|
||||||
|
|||||||
+156
-6
@@ -6,6 +6,7 @@ import apiLimiter from '../utils/rateLimiter.js';
|
|||||||
import authMiddleware, { requireAdmin } from '../utils/authMiddleware.js';
|
import authMiddleware, { requireAdmin } from '../utils/authMiddleware.js';
|
||||||
import { sendToJDownloader } from '../utils/jdownloader.js';
|
import { sendToJDownloader } from '../utils/jdownloader.js';
|
||||||
import { MediaType, SearchResult } from '../types/source.js';
|
import { MediaType, SearchResult } from '../types/source.js';
|
||||||
|
import { enrichSearchResults } from '../utils/tmdbEnricher.js';
|
||||||
import { CONFIG } from '../utils/config.js';
|
import { CONFIG } from '../utils/config.js';
|
||||||
import { getAllUsers, createUserWithGeneratedPassword, deleteUser, resetPassword, updateUserPreferences } from '../utils/userStore.js';
|
import { getAllUsers, createUserWithGeneratedPassword, deleteUser, resetPassword, updateUserPreferences } from '../utils/userStore.js';
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ router.post('/api/set-sources', apiLimiter, authMiddleware, async (req, res) =>
|
|||||||
await checkSiteStatus();
|
await checkSiteStatus();
|
||||||
} else {
|
} else {
|
||||||
// Si seul l'ordre a changé, reconstruire les tendances depuis le cache en mémoire
|
// Si seul l'ordre a changé, reconstruire les tendances depuis le cache en mémoire
|
||||||
rebuildTrendingFromCache();
|
await rebuildTrendingFromCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, activeSources: globalState.activeSources });
|
res.json({ success: true, activeSources: globalState.activeSources });
|
||||||
@@ -137,13 +138,20 @@ router.post('/api/search', apiLimiter, authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
return res.status(404).json({ error: "Aucun résultat trouvé." });
|
return res.status(404).json({ error: "Aucun résultat trouvé." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allResults = await enrichSearchResults(allResults);
|
||||||
|
|
||||||
res.json(allResults);
|
res.json(allResults);
|
||||||
} else {
|
} else {
|
||||||
// Return grouped results
|
// Return grouped results
|
||||||
const grouped: Record<string, SearchResult[] | { error: string }> = {};
|
const grouped: Record<string, SearchResult[] | { error: string }> = {};
|
||||||
allResultsRaw.forEach(item => {
|
for (const item of allResultsRaw) {
|
||||||
grouped[item.sourceName] = item.results || { error: item.error! };
|
if (item.results) {
|
||||||
});
|
grouped[item.sourceName] = await enrichSearchResults(item.results);
|
||||||
|
} else {
|
||||||
|
grouped[item.sourceName] = { error: item.error! };
|
||||||
|
}
|
||||||
|
}
|
||||||
res.json(grouped);
|
res.json(grouped);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -174,6 +182,20 @@ const handleSelectContent: express.RequestHandler = async (req, res) => {
|
|||||||
globalState.isSeries = selection.isSeries;
|
globalState.isSeries = selection.isSeries;
|
||||||
globalState.currentLiens = selection.links;
|
globalState.currentLiens = selection.links;
|
||||||
|
|
||||||
|
// Tri des liens selon l'ordre préféré
|
||||||
|
const { CONFIG } = await import('../utils/config.js');
|
||||||
|
const preferredHosters: string[] = CONFIG.PREFERRED_HOSTERS || [];
|
||||||
|
|
||||||
|
selection.links.sort((a: any, b: any) => {
|
||||||
|
const getIndex = (host: string) => {
|
||||||
|
if (!host) return 999;
|
||||||
|
const h = host.toLowerCase();
|
||||||
|
const idx = preferredHosters.findIndex(pref => h.includes(pref.toLowerCase()) || pref.toLowerCase().includes(h));
|
||||||
|
return idx === -1 ? 999 : idx;
|
||||||
|
};
|
||||||
|
return getIndex(a.host) - getIndex(b.host);
|
||||||
|
});
|
||||||
|
|
||||||
selection.links.forEach((link: any, i: number) => {
|
selection.links.forEach((link: any, i: number) => {
|
||||||
const key = link.id != null ? String(link.id) : String(i);
|
const key = link.id != null ? String(link.id) : String(i);
|
||||||
if (link.url) globalState.directUrlMap[key] = link.url;
|
if (link.url) globalState.directUrlMap[key] = link.url;
|
||||||
@@ -207,7 +229,7 @@ router.post('/api/get-link', apiLimiter, authMiddleware, async (req, res) => {
|
|||||||
console.log(`\n--- Get Link [${activeSource?.name.toUpperCase()}]: ID ${chosenId} pour "${currentTitleName}" (JD: ${useJD}) ---`);
|
console.log(`\n--- Get Link [${activeSource?.name.toUpperCase()}]: ID ${chosenId} pour "${currentTitleName}" (JD: ${useJD}) ---`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let finalLink: string | null = null;
|
let finalLink: any = null;
|
||||||
|
|
||||||
if (directUrlMap[chosenId]) {
|
if (directUrlMap[chosenId]) {
|
||||||
finalLink = directUrlMap[chosenId];
|
finalLink = directUrlMap[chosenId];
|
||||||
@@ -217,6 +239,10 @@ router.post('/api/get-link', apiLimiter, authMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
if (!finalLink) throw new Error("Impossible de résoudre le lien.");
|
if (!finalLink) throw new Error("Impossible de résoudre le lien.");
|
||||||
|
|
||||||
|
if (typeof finalLink === 'object' && finalLink.captcha) {
|
||||||
|
return res.json({ status: 'challenge', challenge: finalLink });
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`🎉 Lien final: ${finalLink}`);
|
console.log(`🎉 Lien final: ${finalLink}`);
|
||||||
|
|
||||||
if (useJD) {
|
if (useJD) {
|
||||||
@@ -248,13 +274,17 @@ router.post('/api/get-links-batch', apiLimiter, authMiddleware, async (req, res)
|
|||||||
|
|
||||||
for (const chosenId of chosenIds) {
|
for (const chosenId of chosenIds) {
|
||||||
try {
|
try {
|
||||||
let finalLink: string | null = null;
|
let finalLink: any = null;
|
||||||
if (directUrlMap[String(chosenId)]) {
|
if (directUrlMap[String(chosenId)]) {
|
||||||
finalLink = directUrlMap[String(chosenId)];
|
finalLink = directUrlMap[String(chosenId)];
|
||||||
} else if (activeSource?.resolveLink) {
|
} else if (activeSource?.resolveLink) {
|
||||||
finalLink = await activeSource.resolveLink(String(chosenId));
|
finalLink = await activeSource.resolveLink(String(chosenId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (finalLink && typeof finalLink === 'object' && finalLink.captcha) {
|
||||||
|
return res.json({ status: 'challenge', challenge: finalLink });
|
||||||
|
}
|
||||||
|
|
||||||
if (finalLink) {
|
if (finalLink) {
|
||||||
results.push(finalLink);
|
results.push(finalLink);
|
||||||
if (useJD !== false && useJD !== 'false') {
|
if (useJD !== false && useJD !== 'false') {
|
||||||
@@ -459,6 +489,95 @@ router.post('/api/admin/users/:id/reset-password', apiLimiter, authMiddleware, r
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** POST /admin/plugins/save — Enregistre la config dynamique des plugins */
|
||||||
|
router.post('/api/admin/plugins/save', apiLimiter, authMiddleware, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { config } = req.body;
|
||||||
|
if (!config || typeof config !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Configuration invalide.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { configManager } = await import('../utils/config.js');
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed !== '') {
|
||||||
|
configManager.set(key, trimmed);
|
||||||
|
} else {
|
||||||
|
configManager.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configManager.save();
|
||||||
|
|
||||||
|
const { sourceRegistry } = await import('../core/registry.js');
|
||||||
|
await sourceRegistry.initialize();
|
||||||
|
|
||||||
|
const { globalState } = await import('../utils/state.js');
|
||||||
|
const availableSources = sourceRegistry.getAvailableNames();
|
||||||
|
|
||||||
|
// Add any newly available sources to globalState.activeSources
|
||||||
|
for (const s of availableSources) {
|
||||||
|
if (!globalState.activeSources.includes(s)) {
|
||||||
|
globalState.activeSources.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove any sources that are no longer available
|
||||||
|
globalState.activeSources = globalState.activeSources.filter(s => availableSources.includes(s));
|
||||||
|
|
||||||
|
const { clearTmdbCache } = await import('../utils/tmdbEnricher.js');
|
||||||
|
clearTmdbCache();
|
||||||
|
|
||||||
|
const { checkSiteStatus } = await import('../utils/state.js');
|
||||||
|
await checkSiteStatus(); // Re-scrape all sites and update trending
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Configuration enregistrée et plugins rechargés.', activeSources: availableSources });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[API] Erreur save plugins:', e.message);
|
||||||
|
res.status(500).json({ error: "Erreur interne lors de l'enregistrement de la configuration." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /admin/plugins/test — Teste et recharge les plugins */
|
||||||
|
router.post('/api/admin/plugins/test', apiLimiter, authMiddleware, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sourceRegistry } = await import('../core/registry.js');
|
||||||
|
await sourceRegistry.initialize();
|
||||||
|
|
||||||
|
const { globalState } = await import('../utils/state.js');
|
||||||
|
const active = sourceRegistry.getAvailableNames();
|
||||||
|
|
||||||
|
for (const s of active) {
|
||||||
|
if (!globalState.activeSources.includes(s)) globalState.activeSources.push(s);
|
||||||
|
}
|
||||||
|
globalState.activeSources = globalState.activeSources.filter(s => active.includes(s));
|
||||||
|
|
||||||
|
res.json({ success: true, activeSources: active });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[API] Erreur test plugins:', e.message);
|
||||||
|
res.status(500).json({ error: "Erreur interne lors du test des plugins." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /admin/hosters/save — Enregistre l'ordre des hébergeurs préférés */
|
||||||
|
router.post('/api/admin/hosters/save', apiLimiter, authMiddleware, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { preferredHosters } = req.body;
|
||||||
|
if (!Array.isArray(preferredHosters)) {
|
||||||
|
return res.status(400).json({ error: 'Format invalide.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { configManager } = await import('../utils/config.js');
|
||||||
|
configManager.set('PREFERRED_HOSTERS', preferredHosters);
|
||||||
|
configManager.save();
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Ordre enregistré.' });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[API] Erreur save hosters:', e.message);
|
||||||
|
res.status(500).json({ error: "Erreur interne lors de l'enregistrement de l'ordre." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// PREFERENCES
|
// PREFERENCES
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -484,4 +603,35 @@ router.post('/api/preferences', apiLimiter, authMiddleware, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/** GET /admin/check-update — Vérifie les mises à jour depuis l'API publique */
|
||||||
|
router.get('/api/admin/check-update', apiLimiter, authMiddleware, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { CONFIG } = await import('../utils/config.js');
|
||||||
|
const updateUrl = 'https://agora.nolhantirer.space/api/public/version';
|
||||||
|
|
||||||
|
// Pour éviter de crasher si l'URL n'existe pas, on met un timeout court.
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(updateUrl, { signal: controller.signal });
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
res.json({ success: true, currentVersion: req.app.locals.appVersion, latestVersion: data.version, notes: data.notes, updateInfo: data });
|
||||||
|
} else {
|
||||||
|
res.status(502).json({ error: 'Serveur de mise à jour injoignable (Erreur HTTP ' + response.status + ').' });
|
||||||
|
}
|
||||||
|
} catch (fetchErr) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
res.status(502).json({ error: 'Serveur de mise à jour injoignable ou hors ligne.' });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[API] Erreur check update:', e.message);
|
||||||
|
res.status(500).json({ error: "Erreur inattendue lors de la vérification de la mise à jour." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
+20
-19
@@ -1,50 +1,51 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import { hasAnyUser, createUser } from '../utils/userStore.js';
|
import { hasAnyUser, createUser } from '../utils/userStore.js';
|
||||||
|
import { configManager, CONFIG, getPluginsToConfigure } from '../utils/config.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// GET /setup — Page de création du premier administrateur
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
// Sécurité : si des users existent déjà, pas d'accès au setup
|
|
||||||
if (hasAnyUser()) {
|
if (hasAnyUser()) {
|
||||||
return res.redirect('/login');
|
return res.redirect('/login');
|
||||||
}
|
}
|
||||||
|
const pluginsToConfigure = getPluginsToConfigure();
|
||||||
res.render('setup', { error: null });
|
res.render('setup', { error: null, pluginsToConfigure });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// POST /setup — Création du premier admin
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
// Sécurité : si des users existent déjà, bloquer
|
|
||||||
if (hasAnyUser()) {
|
if (hasAnyUser()) {
|
||||||
return res.redirect('/login');
|
return res.redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, password, confirmPassword } = req.body;
|
const { username, password, confirmPassword, config } = req.body;
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return res.render('setup', { error: "Tous les champs sont requis." });
|
return res.render('setup', { error: "Tous les champs sont requis.", pluginsToConfigure: getPluginsToConfigure() });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
return res.render('setup', { error: "Les mots de passe ne correspondent pas." });
|
return res.render('setup', { error: "Les mots de passe ne correspondent pas.", pluginsToConfigure: getPluginsToConfigure() });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (config) {
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (value && typeof value === 'string' && value.trim() !== '') {
|
||||||
|
configManager.set(key, value.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configManager.save();
|
||||||
|
}
|
||||||
|
|
||||||
const { user } = createUser(username, password, 'admin');
|
const { user } = createUser(username, password, 'admin');
|
||||||
|
|
||||||
// Connecter automatiquement après setup
|
|
||||||
req.session.regenerate((err) => {
|
req.session.regenerate((err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('[Setup] Erreur session.regenerate:', err);
|
console.error('[Setup] Erreur session.regenerate:', err);
|
||||||
return res.render('setup', { error: "Erreur interne. Réessayez." });
|
return res.render('setup', { error: "Erreur interne. Réessayez.", pluginsToConfigure: getPluginsToConfigure() });
|
||||||
}
|
}
|
||||||
|
|
||||||
(req.session as any).user = {
|
(req.session as any).user = {
|
||||||
@@ -59,7 +60,7 @@ router.post('/', (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[Setup] Erreur création admin:', error.message);
|
console.error('[Setup] Erreur création admin:', error.message);
|
||||||
res.render('setup', { error: error.message });
|
res.render('setup', { error: error.message, pluginsToConfigure: getPluginsToConfigure() });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -65,11 +65,14 @@ router.get('/recent', ...protectedRoute('recent', 'recent'));
|
|||||||
router.get('/search', ...protectedRoute('search', 'search'));
|
router.get('/search', ...protectedRoute('search', 'search'));
|
||||||
router.get('/downloads', ...protectedRoute('downloads', 'downloads'));
|
router.get('/downloads', ...protectedRoute('downloads', 'downloads'));
|
||||||
router.get('/manual', ...protectedRoute('manual', 'manual'));
|
router.get('/manual', ...protectedRoute('manual', 'manual'));
|
||||||
router.get('/settings', viewAuthMiddleware, viewRequireAdmin, (req, res) => {
|
router.get('/settings', viewAuthMiddleware, viewRequireAdmin, async (req, res) => {
|
||||||
const session = req.session as any;
|
const session = req.session as any;
|
||||||
|
const { getPluginsToConfigure, CONFIG } = await import('../utils/config.js');
|
||||||
res.render('settings', {
|
res.render('settings', {
|
||||||
page: 'settings',
|
page: 'settings',
|
||||||
currentUser: session.user || null,
|
currentUser: session.user || null,
|
||||||
|
pluginsToConfigure: getPluginsToConfigure(),
|
||||||
|
preferredHosters: CONFIG.PREFERRED_HOSTERS
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+185
-28
@@ -1,41 +1,198 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const DEV_SECRET = 'hydracked-secret-key-12345';
|
const DEV_SECRET = 'agora-secret-key-12345';
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
if (isProd && (!process.env.SECRET || process.env.SECRET === DEV_SECRET)) {
|
if (isProd && (!process.env.SECRET || process.env.SECRET === DEV_SECRET)) {
|
||||||
throw new Error('[CONFIG] SECRET requis et différent du fallback dev en production.');
|
throw new Error('[CONFIG] SECRET requis et différent du fallback dev en production.');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONFIG = {
|
const CONFIG_PATH = path.resolve(process.cwd(), 'database', 'config.json');
|
||||||
// Plugin ZT (par défaut)
|
const OLD_CONFIG_PATH = path.resolve(process.cwd(), 'config.json');
|
||||||
ZT_URL: process.env.ZT_URL,
|
|
||||||
ZTTEAM_URL: process.env.ZTTEAM_URL,
|
|
||||||
FT_URL: process.env.FT_URL,
|
|
||||||
|
|
||||||
// Plugin Hydracker (optionnel)
|
export class ConfigurationManager {
|
||||||
HYDRACKER_URL: process.env.HYDRACKER_URL || process.env.BASE_URL,
|
private dynamicConfig: Record<string, any> = {};
|
||||||
HYDRACKER_API_KEY: process.env.HYDRACKER_API_KEY || process.env.API_KEY,
|
|
||||||
HYDRACKER_TIMEOUT: parseInt(process.env.HYDRACKER_TIMEOUT || '30000', 10),
|
|
||||||
|
|
||||||
// Local Database (optionnel)
|
constructor() {
|
||||||
DB_PATH: process.env.DB_PATH || './database/darkiworld.db',
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
// App — Auth bootstrap (optionnel, pour création auto du premier admin)
|
public load() {
|
||||||
ADMIN_USERNAME: process.env.ADMIN_USERNAME,
|
// Migration: si un fichier de config existe à la racine, on le déplace dans database/ pour plus de sécurité
|
||||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
|
if (fs.existsSync(OLD_CONFIG_PATH) && !fs.existsSync(CONFIG_PATH)) {
|
||||||
|
try {
|
||||||
|
const dbDir = path.resolve(process.cwd(), 'database');
|
||||||
|
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
fs.copyFileSync(OLD_CONFIG_PATH, CONFIG_PATH);
|
||||||
|
fs.unlinkSync(OLD_CONFIG_PATH);
|
||||||
|
console.log('[CONFIG] Migration de config.json vers database/ réussie.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[CONFIG] Erreur de migration de config.json', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// App
|
if (fs.existsSync(CONFIG_PATH)) {
|
||||||
JD_HOST: process.env.JD_HOST?.trim(),
|
try {
|
||||||
JD_API_PORT: process.env.JD_API_PORT?.trim(),
|
const data = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
||||||
PATHS_JD_SERIES: process.env.PATHS_JD_SERIES,
|
this.dynamicConfig = JSON.parse(data);
|
||||||
PATHS_JD_FILMS: process.env.PATHS_JD_FILMS,
|
} catch (e) {
|
||||||
PATHS_JD_WATCH: process.env.PATHS_JD_WATCH,
|
console.error('[CONFIG] Erreur de lecture de database/config.json', e);
|
||||||
JD_CREATE_SUBFOLDER: process.env.JD_CREATE_SUBFOLDER === 'true',
|
}
|
||||||
JD_AUTOSTART: process.env.JD_AUTOSTART === 'true',
|
}
|
||||||
SECRET: process.env.SECRET || DEV_SECRET,
|
}
|
||||||
MIN_MINUTES: parseInt(process.env.MIN_MINUTES || '15', 10),
|
|
||||||
MAX_MINUTES: parseInt(process.env.MAX_MINUTES || '30', 10),
|
public save() {
|
||||||
PORT: parseInt(process.env.PORT || '3067', 10),
|
try {
|
||||||
};
|
const dbDir = path.dirname(CONFIG_PATH);
|
||||||
|
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(this.dynamicConfig, null, 2), 'utf-8');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[CONFIG] Erreur d\'écriture de database/config.json', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(key: string, fallback?: any): any {
|
||||||
|
if (this.dynamicConfig[key] !== undefined && this.dynamicConfig[key] !== '') {
|
||||||
|
return this.dynamicConfig[key];
|
||||||
|
}
|
||||||
|
if (process.env[key] !== undefined && process.env[key] !== '') {
|
||||||
|
return process.env[key];
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set(key: string, value: any) {
|
||||||
|
this.dynamicConfig[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete(key: string) {
|
||||||
|
delete this.dynamicConfig[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllPluginsConfig(): Record<string, any> {
|
||||||
|
// Renvoie tout sauf les variables systèmes pour l'UI
|
||||||
|
const exclude = ['PORT', 'SECRET', 'ADMIN_USERNAME', 'ADMIN_PASSWORD'];
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
for (const [k, v] of Object.entries(this.dynamicConfig)) {
|
||||||
|
if (!exclude.includes(k)) {
|
||||||
|
result[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const configManager = new ConfigurationManager();
|
||||||
|
|
||||||
|
export const CONFIG = new Proxy({}, {
|
||||||
|
get: (target, prop) => {
|
||||||
|
if (typeof prop !== 'string') return undefined;
|
||||||
|
|
||||||
|
switch(prop) {
|
||||||
|
case 'HYDRACKER_URL': return configManager.get('HYDRACKER_URL', process.env.BASE_URL || '');
|
||||||
|
case 'HYDRACKER_API_KEY': return configManager.get('HYDRACKER_API_KEY', process.env.API_KEY || '');
|
||||||
|
case 'HYDRACKER_TIMEOUT': return parseInt(configManager.get('HYDRACKER_TIMEOUT', '30000'), 10);
|
||||||
|
case 'TMDB_ENABLED': return String(configManager.get('TMDB_ENABLED', 'false')) === 'true';
|
||||||
|
case 'TMDB_API_KEY': return configManager.get('TMDB_API_KEY', '');
|
||||||
|
|
||||||
|
case 'ZT_URL': return configManager.get('ZT_URL', '');
|
||||||
|
case 'ZTTEAM_URL': return configManager.get('ZTTEAM_URL', '');
|
||||||
|
case 'FT_URL': return configManager.get('FT_URL', '');
|
||||||
|
|
||||||
|
case 'FS24_URL': return configManager.get('FS24_URL', '');
|
||||||
|
case 'FS24_USERNAME': return configManager.get('FS24_USERNAME', '');
|
||||||
|
case 'FS24_PASSWORD': return configManager.get('FS24_PASSWORD', '');
|
||||||
|
|
||||||
|
case 'MOVIX_URL': return configManager.get('MOVIX_URL', '');
|
||||||
|
|
||||||
|
case 'FLIXART_URL': return configManager.get('FLIXART_URL', '');
|
||||||
|
case 'FLIXART_USERNAME': return configManager.get('FLIXART_USERNAME', '');
|
||||||
|
case 'FLIXART_PASSWORD': return configManager.get('FLIXART_PASSWORD', '');
|
||||||
|
|
||||||
|
case 'LOADIX_URL': return configManager.get('LOADIX_URL', '');
|
||||||
|
|
||||||
|
case 'DB_PATH': return configManager.get('DB_PATH', './database/darkiworld.db');
|
||||||
|
|
||||||
|
case 'JD_HOST': return configManager.get('JD_HOST')?.trim();
|
||||||
|
case 'JD_API_PORT': return String(configManager.get('JD_API_PORT') || '').trim() || undefined;
|
||||||
|
case 'JD_CREATE_SUBFOLDER': return String(configManager.get('JD_CREATE_SUBFOLDER')) === 'true';
|
||||||
|
case 'JD_AUTOSTART': return String(configManager.get('JD_AUTOSTART')) === 'true';
|
||||||
|
case 'JD_FORCED_START': return String(configManager.get('JD_FORCED_START')) === 'true';
|
||||||
|
|
||||||
|
case 'SECRET': return configManager.get('SECRET', DEV_SECRET);
|
||||||
|
case 'MIN_MINUTES': return parseInt(configManager.get('MIN_MINUTES', '15'), 10);
|
||||||
|
case 'MAX_MINUTES': return parseInt(configManager.get('MAX_MINUTES', '30'), 10);
|
||||||
|
case 'PORT': return parseInt(configManager.get('PORT', '3067'), 10);
|
||||||
|
|
||||||
|
case 'PREFERRED_HOSTERS': {
|
||||||
|
const val = configManager.get('PREFERRED_HOSTERS');
|
||||||
|
if (Array.isArray(val)) return val.filter(h => h.toLowerCase() !== 'uptobox');
|
||||||
|
if (typeof val === 'string') return val.split(',').map(s => s.trim()).filter(h => h.toLowerCase() !== 'uptobox');
|
||||||
|
return ['1fichier', 'turbobit', 'rapidgator', 'nitroflare', 'ddownload', 'mega', 'gofile', 'pixeldrain'];
|
||||||
|
}
|
||||||
|
case 'MAX_RESULTS_PER_SOURCE': return parseInt(configManager.get('MAX_RESULTS_PER_SOURCE', '20'), 10);
|
||||||
|
default: return configManager.get(prop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
export function getPluginsToConfigure() {
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const pluginsDir = path.join(__dirname, '../../plugins');
|
||||||
|
let detected: string[] = [];
|
||||||
|
if (fs.existsSync(pluginsDir)) {
|
||||||
|
detected = fs.readdirSync(pluginsDir, { withFileTypes: true })
|
||||||
|
.filter(d => d.isDirectory())
|
||||||
|
.map(d => d.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginConfigMap: Record<string, { key: string; label: string; placeholder: string; default?: string }[]> = {
|
||||||
|
'ZT': [{ key: 'ZT_URL', label: 'Zone-Téléchargement URL', placeholder: 'https://...', default: CONFIG.ZT_URL }],
|
||||||
|
'ztnews': [{ key: 'ZTTEAM_URL', label: 'ZT (Team) URL', placeholder: 'https://...', default: CONFIG.ZTTEAM_URL }],
|
||||||
|
'freetelecharger': [{ key: 'FT_URL', label: 'Free-Télécharger URL', placeholder: 'https://...', default: CONFIG.FT_URL }],
|
||||||
|
'fs24': [
|
||||||
|
{ key: 'FS24_URL', label: 'FS24 URL', placeholder: 'https://...', default: CONFIG.FS24_URL },
|
||||||
|
{ key: 'FS24_USERNAME', label: 'FS24 Identifiant', placeholder: 'Nom d\'utilisateur...', default: CONFIG.FS24_USERNAME },
|
||||||
|
{ key: 'FS24_PASSWORD', label: 'FS24 Mot de passe', placeholder: 'Mot de passe...', default: CONFIG.FS24_PASSWORD }
|
||||||
|
],
|
||||||
|
'movix': [
|
||||||
|
{ key: 'MOVIX_URL', label: 'Movix URL', placeholder: 'https://...', default: CONFIG.MOVIX_URL }
|
||||||
|
],
|
||||||
|
'flixart': [
|
||||||
|
{ key: 'FLIXART_URL', label: 'FlixArt URL', placeholder: 'https://...', default: CONFIG.FLIXART_URL },
|
||||||
|
{ key: 'FLIXART_USERNAME', label: 'FlixArt Identifiant', placeholder: 'Nom d\'utilisateur...', default: CONFIG.FLIXART_USERNAME },
|
||||||
|
{ key: 'FLIXART_PASSWORD', label: 'FlixArt Mot de passe', placeholder: 'Mot de passe...', default: CONFIG.FLIXART_PASSWORD }
|
||||||
|
],
|
||||||
|
'loadix': [
|
||||||
|
{ key: 'LOADIX_URL', label: 'Loadix API URL', placeholder: 'https://...', default: CONFIG.LOADIX_URL }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const configs = detected.filter(p => pluginConfigMap[p]).map(p => ({
|
||||||
|
name: p,
|
||||||
|
fields: pluginConfigMap[p]
|
||||||
|
}));
|
||||||
|
|
||||||
|
configs.push({
|
||||||
|
name: 'Paramètres Généraux',
|
||||||
|
fields: [
|
||||||
|
{ key: 'MAX_RESULTS_PER_SOURCE', label: 'Limite de résultats par source (Tendances/Récents)', placeholder: 'ex: 20', default: String(CONFIG.MAX_RESULTS_PER_SOURCE) },
|
||||||
|
{ key: 'JD_FORCED_START', label: 'JDownloader : Démarrage Forcé (ignore la file d\'attente)', placeholder: 'true ou false', default: String(CONFIG.JD_FORCED_START === true) }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
configs.push({
|
||||||
|
name: 'TMDB (Enrichissement Auto)',
|
||||||
|
fields: [
|
||||||
|
{ key: 'TMDB_ENABLED', label: 'Activer TMDB', placeholder: 'true ou false', default: String(CONFIG.TMDB_ENABLED === true) },
|
||||||
|
{ key: 'TMDB_API_KEY', label: 'Clé API TMDB (v3 auth)', placeholder: 'Clé API...', default: CONFIG.TMDB_API_KEY }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ export async function sendToJDownloader(link: string, titleName: string, isSerie
|
|||||||
const safeLink = link.trim() + "#movie.mkv";
|
const safeLink = link.trim() + "#movie.mkv";
|
||||||
|
|
||||||
const autoStartStr = CONFIG.JD_AUTOSTART ? 'TRUE' : 'FALSE';
|
const autoStartStr = CONFIG.JD_AUTOSTART ? 'TRUE' : 'FALSE';
|
||||||
|
const forcedStartStr = CONFIG.JD_FORCED_START ? 'TRUE' : 'FALSE';
|
||||||
let fileContent = `text=${safeLink}${lineEnding}`;
|
let fileContent = `text=${safeLink}${lineEnding}`;
|
||||||
fileContent += `enabled=TRUE${lineEnding}`;
|
fileContent += `enabled=TRUE${lineEnding}`;
|
||||||
fileContent += `autoStart=${autoStartStr}${lineEnding}`;
|
fileContent += `autoStart=${autoStartStr}${lineEnding}`;
|
||||||
fileContent += `forcedStart=${autoStartStr}${lineEnding}`;
|
fileContent += `forcedStart=${forcedStartStr}${lineEnding}`;
|
||||||
fileContent += `deepAnalyse=TRUE${lineEnding}`;
|
fileContent += `deepAnalyse=TRUE${lineEnding}`;
|
||||||
fileContent += `autoConfirm=TRUE${lineEnding}`;
|
fileContent += `autoConfirm=TRUE${lineEnding}`;
|
||||||
fileContent += `overwritePackagizerEnabled=TRUE${lineEnding}`;
|
fileContent += `overwritePackagizerEnabled=TRUE${lineEnding}`;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const originalLog = console.log;
|
||||||
|
const originalError = console.error;
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
|
||||||
|
function getTimestamp() {
|
||||||
|
return new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log = (...args) => {
|
||||||
|
originalLog(`[${getTimestamp()}]`, ...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args) => {
|
||||||
|
originalError(`[${getTimestamp()}]`, ...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...args) => {
|
||||||
|
originalWarn(`[${getTimestamp()}]`, ...args);
|
||||||
|
};
|
||||||
+16
-10
@@ -1,5 +1,8 @@
|
|||||||
import { sourceRegistry } from '../core/registry.js';
|
import { sourceRegistry } from '../core/registry.js';
|
||||||
import { ISource, SearchResult } from '../types/source.js';
|
import { ISource, SearchResult } from '../types/source.js';
|
||||||
|
import { loadSettings } from './settingsManager.js';
|
||||||
|
import { enrichSearchResults } from './tmdbEnricher.js';
|
||||||
|
import { CONFIG } from './config.js';
|
||||||
|
|
||||||
export interface GlobalState {
|
export interface GlobalState {
|
||||||
currentTitleId: string | null;
|
currentTitleId: string | null;
|
||||||
@@ -71,9 +74,10 @@ export async function checkSiteStatus() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await Promise.allSettled(sources.map(async (source) => {
|
const results = await Promise.allSettled(sources.map(async (source) => {
|
||||||
const films = await source.getTrending('movie');
|
const limit = CONFIG.MAX_RESULTS_PER_SOURCE;
|
||||||
const series = await source.getTrending('series');
|
const films = (await source.getTrending('movie')).slice(0, limit);
|
||||||
const recent = source.getRecent ? await source.getRecent() : [];
|
const series = (await source.getTrending('series')).slice(0, limit);
|
||||||
|
const recent = (source.getRecent ? await source.getRecent() : []).slice(0, limit);
|
||||||
return { source, films, series, recent };
|
return { source, films, series, recent };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -100,6 +104,7 @@ export async function checkSiteStatus() {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const unique: SearchResult[] = [];
|
const unique: SearchResult[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
if (!item.title) continue;
|
||||||
const key = item.title.toLowerCase().replace(/[^a-z0-9]/g, '');
|
const key = item.title.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
@@ -109,9 +114,9 @@ export async function checkSiteStatus() {
|
|||||||
return unique;
|
return unique;
|
||||||
};
|
};
|
||||||
|
|
||||||
globalState.trendingFilms = deduplicate(allFilms);
|
globalState.trendingFilms = await enrichSearchResults(deduplicate(allFilms));
|
||||||
globalState.trendingSeries = deduplicate(allSeries);
|
globalState.trendingSeries = await enrichSearchResults(deduplicate(allSeries));
|
||||||
globalState.recentItems = deduplicate(allRecent);
|
globalState.recentItems = await enrichSearchResults(deduplicate(allRecent));
|
||||||
|
|
||||||
if (onlineSourcesCount > 0) {
|
if (onlineSourcesCount > 0) {
|
||||||
globalState.isSiteOffline = false;
|
globalState.isSiteOffline = false;
|
||||||
@@ -139,7 +144,7 @@ const sourceTrendsCache = new Map<string, { films: SearchResult[], series: Searc
|
|||||||
* Reconstruit globalState.trendingFilms, trendingSeries et recentItems à partir du cache
|
* Reconstruit globalState.trendingFilms, trendingSeries et recentItems à partir du cache
|
||||||
* en respectant l'ordre de priorité défini dans globalState.activeSources.
|
* en respectant l'ordre de priorité défini dans globalState.activeSources.
|
||||||
*/
|
*/
|
||||||
export function rebuildTrendingFromCache() {
|
export async function rebuildTrendingFromCache() {
|
||||||
let allFilms: SearchResult[] = [];
|
let allFilms: SearchResult[] = [];
|
||||||
let allSeries: SearchResult[] = [];
|
let allSeries: SearchResult[] = [];
|
||||||
let allRecent: SearchResult[] = [];
|
let allRecent: SearchResult[] = [];
|
||||||
@@ -158,6 +163,7 @@ export function rebuildTrendingFromCache() {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const unique: SearchResult[] = [];
|
const unique: SearchResult[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
if (!item.title) continue;
|
||||||
const key = item.title.toLowerCase().replace(/[^a-z0-9]/g, '');
|
const key = item.title.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
@@ -167,8 +173,8 @@ export function rebuildTrendingFromCache() {
|
|||||||
return unique;
|
return unique;
|
||||||
};
|
};
|
||||||
|
|
||||||
globalState.trendingFilms = deduplicate(allFilms);
|
globalState.trendingFilms = await enrichSearchResults(deduplicate(allFilms));
|
||||||
globalState.trendingSeries = deduplicate(allSeries);
|
globalState.trendingSeries = await enrichSearchResults(deduplicate(allSeries));
|
||||||
globalState.recentItems = deduplicate(allRecent);
|
globalState.recentItems = await enrichSearchResults(deduplicate(allRecent));
|
||||||
console.log(`[Cache] Tendances reconstruites en mémoire pour ${sources.length} sources actives.`);
|
console.log(`[Cache] Tendances reconstruites en mémoire pour ${sources.length} sources actives.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { SearchResult } from '../types/source.js';
|
||||||
|
import { CONFIG } from './config.js';
|
||||||
|
|
||||||
|
// Cache (CleanTitle+Type => TmdbData)
|
||||||
|
interface TmdbData {
|
||||||
|
year: string | null;
|
||||||
|
title: string;
|
||||||
|
image: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmdbCache = new Map<string, TmdbData | null>();
|
||||||
|
const MAX_CACHE_SIZE = 500;
|
||||||
|
|
||||||
|
function enforceCacheLimit() {
|
||||||
|
if (tmdbCache.size > MAX_CACHE_SIZE) {
|
||||||
|
let i = 0;
|
||||||
|
for (const key of tmdbCache.keys()) {
|
||||||
|
tmdbCache.delete(key);
|
||||||
|
if (++i > 50) break; // Remove oldest 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTmdbCache() {
|
||||||
|
tmdbCache.clear();
|
||||||
|
console.log("[TMDB] Cache réinitialisé.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanTitle(title: string): string {
|
||||||
|
// Décode les entités HTML fréquentes
|
||||||
|
let clean = title.replace(/'|'/g, "'")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
clean = clean.replace(/\b(saison|season)\s*\d+.*$/i, '');
|
||||||
|
clean = clean.replace(/\b(complete|french|truefrench|vostfr|multi|web-dl|1080p|720p|4k|x265|x264|bluray|bdrip)\b/gi, '');
|
||||||
|
clean = clean.replace(/,\s*le film\b/i, '');
|
||||||
|
clean = clean.replace(/[-_\[\]\(\):]/g, ' ');
|
||||||
|
clean = clean.replace(/\s{2,}/g, ' ');
|
||||||
|
return clean.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTmdbData(title: string, type: string): Promise<TmdbData | null> {
|
||||||
|
if (!CONFIG.TMDB_ENABLED || !CONFIG.TMDB_API_KEY) return null;
|
||||||
|
|
||||||
|
const clean = cleanTitle(title);
|
||||||
|
if (!clean) return null;
|
||||||
|
|
||||||
|
const searchType = (type === 'series' || type === 'serie' || type === 'anime') ? 'tv' : 'movie';
|
||||||
|
const cacheKey = `${searchType}:${clean.toLowerCase()}`;
|
||||||
|
|
||||||
|
if (tmdbCache.has(cacheKey)) {
|
||||||
|
return tmdbCache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[TMDB] Recherche API: Type "${searchType}", Requête "${clean}" (Original: "${title}")`);
|
||||||
|
const url = `https://api.themoviedb.org/3/search/${searchType}?api_key=${CONFIG.TMDB_API_KEY}&query=${encodeURIComponent(clean)}&language=fr-FR&page=1`;
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`[TMDB] Erreur HTTP ${res.status} pour "${clean}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
let resultData: TmdbData | null = null;
|
||||||
|
|
||||||
|
if (data.results && data.results.length > 0) {
|
||||||
|
const first = data.results[0];
|
||||||
|
const dateStr = searchType === 'tv' ? first.first_air_date : first.release_date;
|
||||||
|
const year = (dateStr && typeof dateStr === 'string') ? dateStr.substring(0, 4) : null;
|
||||||
|
const tmdbTitle = searchType === 'tv' ? first.name : first.title;
|
||||||
|
const image = first.poster_path ? `https://image.tmdb.org/t/p/w300${first.poster_path}` : null;
|
||||||
|
|
||||||
|
resultData = { year, title: tmdbTitle, image };
|
||||||
|
console.log(`[TMDB] ✅ Trouvé "${clean}" -> ${tmdbTitle} (${year || 'N/A'})`);
|
||||||
|
} else {
|
||||||
|
console.log(`[TMDB] ❌ Aucun résultat pour "${clean}" en tant que ${searchType}.`);
|
||||||
|
// Fallback: search as the opposite type
|
||||||
|
const fallbackType = searchType === 'movie' ? 'tv' : 'movie';
|
||||||
|
console.log(`[TMDB] 🔄 Fallback: recherche "${clean}" en tant que ${fallbackType}...`);
|
||||||
|
const fallbackUrl = `https://api.themoviedb.org/3/search/${fallbackType}?api_key=${CONFIG.TMDB_API_KEY}&query=${encodeURIComponent(clean)}&language=fr-FR&page=1`;
|
||||||
|
const fbRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(3000) });
|
||||||
|
if (fbRes.ok) {
|
||||||
|
const fbData = await fbRes.json();
|
||||||
|
if (fbData.results && fbData.results.length > 0) {
|
||||||
|
const first = fbData.results[0];
|
||||||
|
const dateStr = fallbackType === 'tv' ? first.first_air_date : first.release_date;
|
||||||
|
const year = (dateStr && typeof dateStr === 'string') ? dateStr.substring(0, 4) : null;
|
||||||
|
const tmdbTitle = fallbackType === 'tv' ? first.name : first.title;
|
||||||
|
const image = first.poster_path ? `https://image.tmdb.org/t/p/w300${first.poster_path}` : null;
|
||||||
|
|
||||||
|
resultData = { year, title: tmdbTitle, image };
|
||||||
|
console.log(`[TMDB] ✅ Trouvé (Fallback) "${clean}" -> ${tmdbTitle} (${year || 'N/A'})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enforceCacheLimit();
|
||||||
|
tmdbCache.set(cacheKey, resultData);
|
||||||
|
return resultData;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[TMDB] ❌ Erreur recherche pour "${clean}":`, (e as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichSearchResults(results: SearchResult[]): Promise<SearchResult[]> {
|
||||||
|
if (!CONFIG.TMDB_ENABLED || !CONFIG.TMDB_API_KEY) return results;
|
||||||
|
|
||||||
|
const enrichmentPromises = results.map(async (r) => {
|
||||||
|
// We now enrich ALWAYS, not just when !r.year, so ZT benefits from it.
|
||||||
|
const tmdbData = await fetchTmdbData(r.title, r.type || 'movie');
|
||||||
|
if (tmdbData) {
|
||||||
|
if (tmdbData.year) r.year = tmdbData.year;
|
||||||
|
if (tmdbData.image) r.image = tmdbData.image;
|
||||||
|
// Clean up original title by removing the HTML entities if TMDB didn't return a title
|
||||||
|
r.title = tmdbData.title || cleanTitle(r.title);
|
||||||
|
} else {
|
||||||
|
// Still decode HTML entities if TMDB fails
|
||||||
|
r.title = cleanTitle(r.title);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.allSettled(enrichmentPromises);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { FlixArtAuth } from './plugins/flixart/auth.js';
|
||||||
|
import { CONFIG } from './src/utils/config.js';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
console.log('Testing FlixArt Auth...');
|
||||||
|
const cookie = await FlixArtAuth.getCookie(true);
|
||||||
|
console.log('Cookie:', cookie);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
+5
-4
@@ -3,14 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Hydr'Hacked — Connexion</title>
|
<title>Agora — Connexion</title>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#1a1a1a">
|
<meta name="theme-color" content="#1a1a1a">
|
||||||
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Hydr'Hacked">
|
<meta name="apple-mobile-web-app-title" content="Agora">
|
||||||
<link rel="apple-touch-icon" href="/images/icone-192.png">
|
<link rel="apple-touch-icon" href="/images/icone-192.png">
|
||||||
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
@@ -25,14 +25,15 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/logo_svg.svg">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id="login-overlay" class="overlay active">
|
<div id="login-overlay" class="overlay active">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
<div class="logo-large">
|
<div class="logo-large">
|
||||||
<img src="/images/logo_transparent.png" alt="Logo" class="icon-xl">
|
<img src="/images/logo_svg.svg" alt="Logo" class="icon-xl">
|
||||||
<h1>Hydr'Hacked</h1>
|
<h1>Agora</h1>
|
||||||
</div>
|
</div>
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<input type="text" id="login-username" placeholder="Nom d'utilisateur" required autofocus autocomplete="username">
|
<input type="text" id="login-username" placeholder="Nom d'utilisateur" required autofocus autocomplete="username">
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
<div style="margin-bottom: 1.5rem;">
|
<div style="margin-bottom: 1.5rem;">
|
||||||
<label for="manual-links" style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: white;">Liens (un lien par ligne)</label>
|
<label for="manual-links" style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: white;">Liens (un lien par ligne)</label>
|
||||||
<textarea id="manual-links" placeholder="Copiez-collez vos liens ici (1fichier, uptobox, etc.) Un lien par ligne..." style="width: 100%; height: 180px; padding: 12px; background: var(--bg-main); border: 1px solid var(--border); color: white; border-radius: var(--radius); font-family: monospace; line-height: 1.5; resize: vertical;"></textarea>
|
<textarea id="manual-links" placeholder="Copiez-collez vos liens ici (1fichier, turbobit, etc.) Un lien par ligne..." style="width: 100%; height: 180px; padding: 12px; background: var(--bg-main); border: 1px solid var(--border); color: white; border-radius: var(--radius); font-family: monospace; line-height: 1.5; resize: vertical;"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn-primary" id="btn-manual-submit" style="width: 100%; display: flex; justify-content: center; align-items: center; gap: 8px; padding: 12px 24px; font-weight: bold;">
|
<button class="btn-primary" id="btn-manual-submit" style="width: 100%; display: flex; justify-content: center; align-items: center; gap: 8px; padding: 12px 24px; font-weight: bold;">
|
||||||
|
|||||||
@@ -3,19 +3,20 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Hydr'Hacked</title>
|
<title>Agora</title>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#1a1a1a">
|
<meta name="theme-color" content="#1a1a1a">
|
||||||
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Hydr'Hacked">
|
<meta name="apple-mobile-web-app-title" content="Agora">
|
||||||
<link rel="apple-touch-icon" href="/images/icone-192.png">
|
<link rel="apple-touch-icon" href="/images/icone-192.png">
|
||||||
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<script src="/lucide.min.js"></script>
|
<script src="/lucide.min.js"></script>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/logo_svg.svg">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app-container">
|
<div id="app-container">
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
</script>
|
</script>
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-logo">
|
<div class="brand-logo">
|
||||||
<img src="/images/logo_transparent.png" alt="Logo" class="brand-icon">
|
<img src="/images/logo_svg.svg" alt="Logo" class="brand-icon">
|
||||||
<span>Hydr'Hacked</span>
|
<span>Agora</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="btn-sidebar-toggle" class="btn-sidebar-toggle" title="Réduire la barre latérale">
|
<button id="btn-sidebar-toggle" class="btn-sidebar-toggle" title="Réduire la barre latérale">
|
||||||
<i data-lucide="chevron-left" id="sidebar-toggle-icon"></i>
|
<i data-lucide="chevron-left" id="sidebar-toggle-icon"></i>
|
||||||
|
|||||||
@@ -8,6 +8,21 @@
|
|||||||
<p>Les derniers ajouts disponibles sur la source</p>
|
<p>Les derniers ajouts disponibles sur la source</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-pills" style="margin-bottom: 2rem;">
|
||||||
|
<label class="pill">
|
||||||
|
<input type="radio" name="recent-type" value="all" checked>
|
||||||
|
<span>Tout</span>
|
||||||
|
</label>
|
||||||
|
<label class="pill">
|
||||||
|
<input type="radio" name="recent-type" value="film">
|
||||||
|
<span>Films</span>
|
||||||
|
</label>
|
||||||
|
<label class="pill">
|
||||||
|
<input type="radio" name="recent-type" value="serie">
|
||||||
|
<span>Séries</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="recent-grid" class="media-grid">
|
<div id="recent-grid" class="media-grid">
|
||||||
<div class="loader-wrapper">
|
<div class="loader-wrapper">
|
||||||
<div class="loader"></div>
|
<div class="loader"></div>
|
||||||
|
|||||||
+288
-6
@@ -1,4 +1,5 @@
|
|||||||
<%- include('partials/header') %>
|
<%- include('partials/header') %>
|
||||||
|
<script src="/sortable.min.js"></script>
|
||||||
<%- include('partials/sidebar') %>
|
<%- include('partials/sidebar') %>
|
||||||
|
|
||||||
<main class="content-wrapper">
|
<main class="content-wrapper">
|
||||||
@@ -7,6 +8,25 @@
|
|||||||
<h2>Paramètres</h2>
|
<h2>Paramètres</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if (typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin') { %>
|
||||||
|
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
|
||||||
|
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<span style="display: flex; align-items: center; gap: 8px;"><i data-lucide="refresh-cw"></i> Système & Mises à jour</span>
|
||||||
|
<span style="font-size: 0.75rem; background: rgba(255,255,255,0.1); padding: 4px 8px; border-radius: 4px; font-family: monospace;">v<%= appVersion %></span>
|
||||||
|
</h3>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
|
||||||
|
Vérifiez si une nouvelle version d'Agora est disponible et consultez les instructions de mise à jour.
|
||||||
|
</p>
|
||||||
|
<button id="check-update-btn" class="btn-action" style="padding: 10px 20px; display: flex; align-items: center; justify-content: center; gap: 8px;">
|
||||||
|
<i data-lucide="download-cloud" style="width: 16px; height: 16px;"></i> Vérifier les mises à jour
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="update-results-container" style="display: none; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px dashed var(--border);">
|
||||||
|
<!-- Injecté par JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
|
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
|
||||||
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
|
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
|
||||||
<i data-lucide="database"></i> Sources de Recherche
|
<i data-lucide="database"></i> Sources de Recherche
|
||||||
@@ -26,14 +46,113 @@
|
|||||||
<div id="sources-sortable-container" style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 1rem;">
|
<div id="sources-sortable-container" style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 1rem;">
|
||||||
<!-- Les sources ordonnables seront injectées ici par JS -->
|
<!-- Les sources ordonnables seront injectées ici par JS -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="primary-source-warning" style="font-size: 0.8rem; color: #f59e0b; margin-top: 12px; display: none; align-items: center; gap: 6px; font-weight: 500; background: rgba(245, 158, 11, 0.1); padding: 8px 12px; border-radius: 6px; border: 1px solid rgba(245, 158, 11, 0.2);">
|
|
||||||
<i data-lucide="alert-triangle" style="width: 16px; height: 16px; flex-shrink: 0;"></i>
|
|
||||||
Hydracker est la première source active. Elle peut être très lente à répondre.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if (typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin') { %>
|
||||||
|
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
|
||||||
|
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i data-lucide="server"></i> Hébergeurs Préférés
|
||||||
|
</h3>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
|
||||||
|
Glissez et déposez les hébergeurs pour définir leur ordre de préférence. Les liens de téléchargement seront triés selon cet ordre.
|
||||||
|
</p>
|
||||||
|
<div id="hosters-sortable-container" style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 1rem;">
|
||||||
|
<% if (typeof preferredHosters !== 'undefined' && preferredHosters.length > 0) { %>
|
||||||
|
<% preferredHosters.forEach(function(hoster) {
|
||||||
|
const h = hoster.toLowerCase();
|
||||||
|
let domain = h + '.com';
|
||||||
|
if (h === 'turbobit') domain = 'turbobit.net';
|
||||||
|
if (h === 'rapidgator') domain = 'rapidgator.net';
|
||||||
|
if (h === 'mega') domain = 'mega.nz';
|
||||||
|
if (h === 'gofile') domain = 'gofile.io';
|
||||||
|
if (h === 'nitroflare') domain = 'nitroflare.com';
|
||||||
|
%>
|
||||||
|
<div class="sortable-item" data-hoster="<%= hoster %>" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: var(--bg-main); border: 1px solid var(--border); border-radius: 8px; cursor: grab;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
|
<i data-lucide="grip-vertical" style="color: var(--text-sec); width: 18px; height: 18px;"></i>
|
||||||
|
<img src="https://www.google.com/s2/favicons?domain=<%= domain %>&sz=32" alt="logo" style="width: 20px; height: 20px; border-radius: 4px;" onerror="this.style.display='none'; this.nextElementSibling.style.display='inline-block';">
|
||||||
|
<i data-lucide="server" style="color: var(--text-sec); width: 18px; height: 18px; display: none;"></i>
|
||||||
|
<span style="font-weight: 500; text-transform: capitalize;"><%= hoster %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<button id="save-hosters-btn" class="btn-action" style="padding: 10px 20px; display: flex; align-items: center; justify-content: center; gap: 8px; flex: 1;">
|
||||||
|
<i data-lucide="save" style="width: 16px; height: 16px;"></i> Enregistrer l'ordre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
|
||||||
|
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i data-lucide="plug"></i> Configuration des Plugins
|
||||||
|
</h3>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
|
||||||
|
Modifiez les adresses et clés d'accès des plugins. Les modifications seront appliquées immédiatement sans redémarrage.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="plugins-config-form">
|
||||||
|
<% if (typeof pluginsToConfigure !== 'undefined' && pluginsToConfigure.length > 0) { %>
|
||||||
|
<% pluginsToConfigure.forEach(function(plugin) { %>
|
||||||
|
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 1rem;">
|
||||||
|
<strong style="display: flex; align-items: center; gap: 6px; margin-bottom: 0.8rem; color: var(--text-main); font-size: 0.95rem; text-transform: capitalize;">
|
||||||
|
<i data-lucide="box" style="width: 14px; height: 14px; color: var(--text-sec);"></i>
|
||||||
|
<%= plugin.name %>
|
||||||
|
</strong>
|
||||||
|
<%
|
||||||
|
const urlField = plugin.fields.find(f => f.key.endsWith('_URL'));
|
||||||
|
const urlEmpty = urlField && !urlField.default;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<% if (plugin.name.includes('TMDB')) { %>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 12px; margin-top: -8px;">
|
||||||
|
<i data-lucide="info" style="width: 14px; height: 14px; vertical-align: middle;"></i>
|
||||||
|
Pour obtenir une clé d'API, créez un compte gratuit sur <a href="https://www.themoviedb.org/settings/api" target="_blank" style="color: var(--primary);">themoviedb.org</a>, générez une clé (API v3 Auth) et collez-la ci-dessous.
|
||||||
|
</p>
|
||||||
|
<% plugin.fields.forEach(function(field) { %>
|
||||||
|
<div style="margin-bottom: 0.8rem;">
|
||||||
|
<label for="config_<%= field.key %>" style="font-size: 0.8rem; color: var(--text-sec); margin-bottom: 4px; display: block;"><%= field.label %></label>
|
||||||
|
<% if (field.key.endsWith('_ENABLED') || field.key === 'JD_FORCED_START') { %>
|
||||||
|
<select id="config_<%= field.key %>" name="<%= field.key %>" style="width: 100%; padding: 10px; background: rgba(0,0,0,0.2); border: 1px solid var(--border); border-radius: 6px; color: var(--text-main); font-size: 0.9rem;">
|
||||||
|
<option value="true" <%= field.default === 'true' ? 'selected' : '' %>>Activé</option>
|
||||||
|
<option value="false" <%= field.default === 'false' ? 'selected' : '' %>>Désactivé</option>
|
||||||
|
</select>
|
||||||
|
<% } else { %>
|
||||||
|
<input type="text" id="config_<%= field.key %>" name="<%= field.key %>"
|
||||||
|
placeholder="<%= field.placeholder %>" value="<%= field.default || '' %>"
|
||||||
|
style="width: 100%; padding: 10px; background: rgba(0,0,0,0.2); border: 1px solid var(--border); border-radius: 6px; color: var(--text-main); font-size: 0.9rem;">
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %>
|
||||||
|
<% plugin.fields.forEach(function(field) { %>
|
||||||
|
<div style="margin-bottom: 0.8rem;">
|
||||||
|
<label for="config_<%= field.key %>" style="font-size: 0.8rem; color: var(--text-sec); margin-bottom: 4px; display: block;"><%= field.label %></label>
|
||||||
|
<input type="text" id="config_<%= field.key %>" name="<%= field.key %>"
|
||||||
|
placeholder="<%= field.placeholder %>" value="<%= field.default || '' %>"
|
||||||
|
data-is-url="<%= field.key.endsWith('_URL') ? 'true' : 'false' %>"
|
||||||
|
data-plugin="<%= plugin.name %>"
|
||||||
|
style="padding: 8px; font-size: 0.9rem; width: 100%; border: 1px solid var(--border); background: var(--bg-main); color: var(--text-main); border-radius: 6px;">
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<button type="submit" class="btn-action" style="padding: 10px 20px; display: flex; align-items: center; justify-content: center; gap: 8px; flex: 1;">
|
||||||
|
<i data-lucide="save" style="width: 16px; height: 16px;"></i> Enregistrer & Recharger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-sec);">Aucun plugin détecté.</p>
|
||||||
|
<% } %>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
|
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
|
||||||
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem;">
|
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem;">
|
||||||
Page d'accueil par défaut</h3>
|
Page d'accueil par défaut</h3>
|
||||||
@@ -118,10 +237,173 @@
|
|||||||
<div style="text-align: center; margin-top: 3rem; margin-bottom: 1rem;">
|
<div style="text-align: center; margin-top: 3rem; margin-bottom: 1rem;">
|
||||||
<p style="font-size: 0.85rem; color: var(--text-sec); display: inline-flex; align-items: center; gap: 6px; background: var(--bg-card); padding: 6px 12px; border-radius: 20px; border: 1px solid var(--border);">
|
<p style="font-size: 0.85rem; color: var(--text-sec); display: inline-flex; align-items: center; gap: 6px; background: var(--bg-card); padding: 6px 12px; border-radius: 20px; border: 1px solid var(--border);">
|
||||||
<i data-lucide="info" style="width: 14px; height: 14px;"></i>
|
<i data-lucide="info" style="width: 14px; height: 14px;"></i>
|
||||||
Hydr'Hacked v1.4.8
|
Agora v<%= appVersion %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// --- Plugins Config Form ---
|
||||||
|
const form = document.getElementById('plugins-config-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = form.querySelector('button[type="submit"]');
|
||||||
|
const origHtml = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i data-lucide="loader-2" class="spin" style="width: 16px; height: 16px;"></i> Enregistrement...';
|
||||||
|
btn.disabled = true;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const config = Object.fromEntries(fd.entries());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/plugins/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ config })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
window.showModal(
|
||||||
|
'Succès',
|
||||||
|
`<div style="padding: 1rem; text-align: center; color: var(--text-main);">
|
||||||
|
<i data-lucide="check-circle" style="color: #10b981; width: 48px; height: 48px; margin-bottom: 1rem;"></i>
|
||||||
|
<p>Configuration enregistrée avec succès.</p>
|
||||||
|
<p style="font-size: 0.9rem; color: var(--text-sec); margin-top: 0.5rem;">Plugins actifs : ${data.activeSources.join(', ')}</p>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
|
} else {
|
||||||
|
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>${data.error}</p></div>`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>Erreur lors de la sauvegarde.</p></div>`);
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
btn.disabled = false;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hosters Sorting ---
|
||||||
|
const hostersContainer = document.getElementById('hosters-sortable-container');
|
||||||
|
if (hostersContainer) {
|
||||||
|
Sortable.create(hostersContainer, {
|
||||||
|
animation: 150,
|
||||||
|
handle: '.sortable-item', // Permet de draguer sur tout l'item
|
||||||
|
ghostClass: 'dragging-hoster', // Classe appliquée pendant le drag
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('save-hosters-btn')?.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
const origHtml = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i data-lucide="loader-2" class="spin" style="width: 16px; height: 16px;"></i> Enregistrement...';
|
||||||
|
btn.disabled = true;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
|
||||||
|
const order = Array.from(hostersContainer.querySelectorAll('.sortable-item')).map(el => el.dataset.hoster);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/hosters/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ preferredHosters: order })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
window.showModal(
|
||||||
|
'Succès',
|
||||||
|
`<div style="padding: 1rem; text-align: center; color: var(--text-main);">
|
||||||
|
<i data-lucide="check-circle" style="color: #10b981; width: 48px; height: 48px; margin-bottom: 1rem;"></i>
|
||||||
|
<p>Ordre des hébergeurs enregistré avec succès !</p>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
} else {
|
||||||
|
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>${data.error}</p></div>`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>Erreur réseau lors de la sauvegarde.</p></div>`);
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
btn.disabled = false;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Updates Checking ---
|
||||||
|
const checkUpdateBtn = document.getElementById('check-update-btn');
|
||||||
|
if (checkUpdateBtn) {
|
||||||
|
checkUpdateBtn.addEventListener('click', async () => {
|
||||||
|
const btn = checkUpdateBtn;
|
||||||
|
const origHtml = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i data-lucide="loader-2" class="spin" style="width: 16px; height: 16px;"></i> Vérification...';
|
||||||
|
btn.disabled = true;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
|
||||||
|
const container = document.getElementById('update-results-container');
|
||||||
|
container.style.display = 'none';
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/check-update');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
container.style.display = 'block';
|
||||||
|
|
||||||
|
const isNew = data.currentVersion !== data.latestVersion;
|
||||||
|
let html = '';
|
||||||
|
if (isNew) {
|
||||||
|
html += `<div style="background: rgba(16, 185, 129, 0.1); border: 1px solid #10b981; padding: 12px; border-radius: 6px; margin-bottom: 1rem; color: #10b981; font-weight: 600; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i data-lucide="party-popper" style="width: 20px; height: 20px;"></i> Nouvelle version disponible : ${data.latestVersion} !
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
html += `<div style="background: rgba(255, 255, 255, 0.05); border: 1px solid var(--border); padding: 12px; border-radius: 6px; margin-bottom: 1rem; color: var(--text-main); font-weight: 500; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i data-lucide="check-circle" style="color: #10b981; width: 20px; height: 20px;"></i> Votre système est à jour (v${data.currentVersion}).
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.notes && data.notes.length > 0) {
|
||||||
|
html += `<h4 style="margin-bottom: 8px; font-size: 0.9rem;">Nouveautés :</h4><ul style="margin: 0 0 1rem 0; padding-left: 20px; color: var(--text-sec); font-size: 0.85rem; line-height: 1.5;">`;
|
||||||
|
data.notes.forEach(n => html += `<li>${n}</li>`);
|
||||||
|
html += `</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<h4 style="margin-bottom: 8px; font-size: 0.9rem;">Comment mettre à jour :</h4>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
|
<div style="background: var(--bg-main); padding: 12px; border-radius: 6px; border: 1px solid var(--border);">
|
||||||
|
<strong style="display: block; font-size: 0.8rem; color: var(--primary); margin-bottom: 4px;">Via Docker</strong>
|
||||||
|
<code style="font-size: 0.85rem; color: var(--text-main); user-select: all; cursor: text;">${data.updateInfo.dockerCmd}</code>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--bg-main); padding: 12px; border-radius: 6px; border: 1px solid var(--border);">
|
||||||
|
<strong style="display: block; font-size: 0.8rem; color: var(--primary); margin-bottom: 4px;">Installation Manuelle (.zip)</strong>
|
||||||
|
<a href="${data.updateInfo.zipUrl}" target="_blank" style="font-size: 0.85rem; color: var(--text-main); text-decoration: underline;">Télécharger l'archive depuis le site</a>
|
||||||
|
${data.updateInfo.zipSha256 ? `<div style="margin-top: 8px; font-size: 0.8rem; color: var(--text-muted);">Somme de contrôle (SHA-256) :<br><code style="font-size: 0.75rem; user-select: all; background: rgba(0,0,0,0.2); padding: 2px 4px; border-radius: 4px; word-break: break-all;">${data.updateInfo.zipSha256}</code></div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>${data.error}</p></div>`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
window.showModal('Erreur', `<div style="padding: 1rem; text-align: center; color: #ef4444;"><p>Erreur réseau lors de la vérification.</p></div>`);
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = origHtml;
|
||||||
|
btn.disabled = false;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+41
-8
@@ -3,14 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Hydr'Hacked — Installation</title>
|
<title>Agora — Installation</title>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#1a1a1a">
|
<meta name="theme-color" content="#1a1a1a">
|
||||||
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Hydr'Hacked">
|
<meta name="apple-mobile-web-app-title" content="Agora">
|
||||||
<link rel="apple-touch-icon" href="/images/icone-192.png">
|
<link rel="apple-touch-icon" href="/images/icone-192.png">
|
||||||
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
@@ -22,10 +22,11 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem;
|
padding: 2rem 1rem;
|
||||||
background: var(--bg-main);
|
background: var(--bg-main);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.setup-box {
|
.setup-box {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
@@ -33,8 +34,9 @@
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 2.5rem;
|
padding: 2.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 480px;
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
.setup-logo {
|
.setup-logo {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -127,13 +129,14 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/logo_svg.svg">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="setup-container">
|
<div class="setup-container">
|
||||||
<div class="setup-box">
|
<div class="setup-box">
|
||||||
<div class="setup-logo">
|
<div class="setup-logo">
|
||||||
<img src="/images/logo_transparent.png" alt="Logo">
|
<img src="/images/logo_svg.svg" alt="Logo">
|
||||||
<h1>Hydr'Hacked</h1>
|
<h1>Agora</h1>
|
||||||
<p>Configuration initiale</p>
|
<p>Configuration initiale</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -190,11 +193,41 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if (typeof pluginsToConfigure !== 'undefined' && pluginsToConfigure.length > 0) { %>
|
||||||
|
<div style="margin: 2rem 0; border-top: 1px solid var(--border); padding-top: 1.5rem; text-align: left;">
|
||||||
|
<h3 style="margin-bottom: 1rem; color: var(--text-main); font-size: 1.1rem; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i data-lucide="plug" style="width: 18px; height: 18px; color: var(--primary);"></i>
|
||||||
|
Configuration des Plugins (Optionnel)
|
||||||
|
</h3>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1.5rem; line-height: 1.4;">
|
||||||
|
Des sources ont été détectées. Vous pouvez les configurer maintenant ou plus tard dans les paramètres.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<% pluginsToConfigure.forEach(function(plugin) { %>
|
||||||
|
<div style="background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin-bottom: 1rem;">
|
||||||
|
<strong style="display: flex; align-items: center; gap: 6px; margin-bottom: 0.8rem; color: var(--text-main); font-size: 0.95rem; text-transform: capitalize;">
|
||||||
|
<i data-lucide="box" style="width: 14px; height: 14px; color: var(--text-sec);"></i>
|
||||||
|
<%= plugin.name %>
|
||||||
|
</strong>
|
||||||
|
<% plugin.fields.forEach(function(field) { %>
|
||||||
|
<div style="margin-bottom: 0.8rem;">
|
||||||
|
<label for="config_<%= field.key %>" style="font-size: 0.8rem; color: var(--text-sec); margin-bottom: 4px; display: block;"><%= field.label %></label>
|
||||||
|
<input type="text" id="config_<%= field.key %>" name="config[<%= field.key %>]"
|
||||||
|
placeholder="<%= field.placeholder %>" value="<%= field.default || '' %>"
|
||||||
|
style="padding: 8px; font-size: 0.9rem; width: 100%; border: 1px solid var(--border); background: var(--bg-main); color: var(--text-main); border-radius: 6px;">
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<button type="submit" id="setup-submit-btn" disabled>Créer le compte administrateur</button>
|
<button type="submit" id="setup-submit-btn" disabled>Créer le compte administrateur</button>
|
||||||
<div style="text-align: center; margin-top: 3rem; margin-bottom: 1rem;">
|
<div style="text-align: center; margin-top: 3rem; margin-bottom: 1rem;">
|
||||||
<p style="font-size: 0.85rem; color: var(--text-sec); display: inline-flex; align-items: center; gap: 6px; background: var(--bg-card); padding: 6px 12px; border-radius: 20px; border: 1px solid var(--border);">
|
<p style="font-size: 0.85rem; color: var(--text-sec); display: inline-flex; align-items: center; gap: 6px; background: var(--bg-card); padding: 6px 12px; border-radius: 20px; border: 1px solid var(--border);">
|
||||||
<i data-lucide="info" style="width: 14px; height: 14px;"></i>
|
<i data-lucide="info" style="width: 14px; height: 14px;"></i>
|
||||||
Hydr'Hacked v1.4.8
|
Agora v<%= appVersion %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user