ci: fix pipeline, update readme and anonymize ZT references
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# ============================================
|
||||
# Hydr'Hacked — Configuration
|
||||
# ============================================
|
||||
|
||||
# --- Plugin : Zone-Téléchargement (Source par défaut) ---
|
||||
ZT_URL= #https://...
|
||||
|
||||
|
||||
# --- Autres sources ---
|
||||
# ZTTEAM_URL=
|
||||
# FT_URL=
|
||||
|
||||
# --- Plugin : Base de Données Locale SQLite (Optionnel) ---
|
||||
# DB_PATH=./database/darkiworld.db
|
||||
|
||||
# --- Plugin : Hydracker ---
|
||||
# HYDRACKER_URL= #https://
|
||||
# HYDRACKER_API_KEY= # 15746...
|
||||
# HYDRACKER_TIMEOUT=30000 # Timeout de healthcheck et d'appels API en millisecondes (30s par défaut)
|
||||
|
||||
# --- Configuration Application ---
|
||||
PORT=3067
|
||||
SECRET=generer-une-cle-aleatoire-ici
|
||||
|
||||
# --- Admin auto-bootstrap (Optionnel) ---
|
||||
# Si définis, le compte admin est créé automatiquement au premier lancement.
|
||||
# Si absents, accédez à /setup pour créer le premier admin manuellement.
|
||||
# ADMIN_USERNAME=admin
|
||||
# ADMIN_PASSWORD=hydracked
|
||||
|
||||
# --- Paramètres de scan ---
|
||||
MIN_MINUTES=15
|
||||
MAX_MINUTES=30
|
||||
|
||||
# --- JDownloader (Optionnel) ---
|
||||
# JD_HOST=192.168.1.100
|
||||
# JD_API_PORT=3128
|
||||
# PATHS_JD_WATCH=C:\Users\nom\Documents\Nouveau dossier\
|
||||
|
||||
# ⚠️ Attention : Les chemins PATHS_JD_FILMS et PATHS_JD_SERIES doivent impérativement finir par un '/' ou '\'
|
||||
# PATHS_JD_FILMS=C:\Users\nom\Documents\Nouveau dossier\Films\
|
||||
# PATHS_JD_SERIES=C:\Users\nom\Documents\Nouveau dossier\Series\
|
||||
|
||||
# JD_CREATE_SUBFOLDER=true
|
||||
# JD_AUTOSTART=true
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [NoNoBzH22] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Docker Image CI
|
||||
|
||||
on:
|
||||
# Se déclenche automatiquement quand tu publies une nouvelle "Release" sur GitHub
|
||||
release:
|
||||
types: [published]
|
||||
# Permet de lancer le script manuellement depuis l'interface GitHub (onglet Actions)
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
# On récupère le nom de ton dépôt (ex: nonobzh22/hydr-hacked)
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # Nécessaire pour pousser l'image sur le GitHub Container Registry
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Cette étape met le nom de l'image en minuscules pour éviter les erreurs Docker
|
||||
- name: Lowercase the image name
|
||||
run: echo "IMAGE_NAME=$(echo ${{ env.IMAGE_NAME }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
# GitHub fournit automatiquement ces identifiants lors de l'exécution, pas besoin de les créer !
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' || github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=branch
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
.env
|
||||
sessions/
|
||||
.DS_Store
|
||||
*.crawljob
|
||||
/downloads/
|
||||
dist/
|
||||
database/darkiworld.db
|
||||
database/settings.json
|
||||
database/users.json
|
||||
scripts/
|
||||
@@ -0,0 +1,43 @@
|
||||
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"
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
FROM node:22-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-slim
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/views ./views
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
EXPOSE 3067
|
||||
CMD ["node", "--dns-result-order=ipv4first", "dist/src/index.js"]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Couleurs pour l'affichage
|
||||
BLUE = \033[0;34m
|
||||
GREEN = \033[0;32m
|
||||
YELLOW = \033[0;33m
|
||||
NC = \033[0m
|
||||
|
||||
.PHONY: all install build start launch dev clean help
|
||||
|
||||
# Default target: show help
|
||||
all: help
|
||||
|
||||
help: ## Affiche ce message d'aide
|
||||
@echo "$(BLUE)Commande disponibles :$(NC)"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-15s$(NC) %s\n", $$1, $$2}'
|
||||
|
||||
install: node_modules ## Installation des dépendances
|
||||
|
||||
node_modules: package.json package-lock.json
|
||||
@echo "$(YELLOW)Vérification/Installation des dépendances...$(NC)"
|
||||
npm install
|
||||
@touch node_modules
|
||||
|
||||
build: node_modules ## Compilation TypeScript vers JavaScript
|
||||
@echo "$(YELLOW)Compilation du projet...$(NC)"
|
||||
npm run build
|
||||
@echo "$(GREEN)Compilation terminée!$(NC)"
|
||||
|
||||
start: build ## Compilation et lancement du projet
|
||||
@echo "$(YELLOW)Lancement du projet...$(NC)"
|
||||
npm start
|
||||
|
||||
dev: node_modules ## Lancement en mode développement (hot reload)
|
||||
@echo "$(YELLOW)Lancement du serveur en mode développement...$(NC)"
|
||||
npm run dev
|
||||
|
||||
clean: ## Suppression des fichiers de build et temporaires
|
||||
@echo "$(YELLOW)Nettoyage...$(NC)"
|
||||
@rm -rf dist
|
||||
@rm -rf sessions/*.json 2>/dev/null || true
|
||||
@echo "$(GREEN)Nettoyage terminé!$(NC)"
|
||||
@@ -0,0 +1,179 @@
|
||||
# 🐍 Hydr'Hacked
|
||||
> [!IMPORTANT]
|
||||
> Merci de bien lire tout ça avant de déployer le server
|
||||
> Si vous êtes débutant(e) cette vidéo devrait répondre à vos questions
|
||||
[Vidéo tutoriel + DB](https://gofile.io/d/3CA4rk)
|
||||
|
||||

|
||||
|
||||
> "Un immense merci à l'équipe technique d'Hydracker pour sa générosité. On a trouvé votre API tellement 'ouverte d'esprit' qu'on s'est permis de l'aider à partager ses liens sans les contraintes futiles d'un navigateur ou d'un abonnement. C'est presque trop facile, mais comme on dit : c'est l'intention qui compte." 💅
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Présentation
|
||||
|
||||
**Hydr'Hacked** 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).
|
||||
- 📰 **ZTNews** : Source secondaire gratuite (ZT News) pour des exclusivités et nouveaux ajouts.
|
||||
- ⚡ **FreeTélécharger (FreeTel)** : Source alternative gratuite avec de multiples miroirs.
|
||||
- 🗄️ **LocalDB** : Base de données locale intégrée pour des recherches hors-ligne instantanées (Films, Séries, Jeux, Logiciels, Musique, etc.).
|
||||
- 🛡️ **Hydracker** : Source premium secondaire (nécessite un token et une configuration).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Nouveauté :** 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.
|
||||
> La db locale (LocalDB) est au même endroit que la vidéo tuto ;) au dessus.
|
||||
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
- 🔍 **Recherche & Tendances** : Chercher vos films et séries ou récupérer les tendances.
|
||||
- 🗄️ **Base de Données Locale** : Recherche instantanée et hors-ligne grâce au plugin natif LocalDB.
|
||||
- 💻 **Interface web** : Interface web moderne et responsive (Dark Mode, animations fluides).
|
||||
- 🔗 **Affichage des liens** : Copier-coller le lien final s'affiche en un clic.
|
||||
- ⚡ **Intégration JDownloader** : Envoi automatique des liens vers votre instance JDownloader (si activé dans les paramètres).
|
||||
|
||||
## 🔑 Ce qui nécessite (ou pas) un token
|
||||
|
||||
| Fonctionnalité | 100% gratuit |
|
||||
|---|---|
|
||||
| 🔍 Recherche | ✅ Gratuit (ZT / LocalDB) |
|
||||
| 🔥 Tendances | ✅ Gratuit (ZT) |
|
||||
| 🎬 Films (liens 1fichier) | ✅ Gratuit (ZT / LocalDB) |
|
||||
| 🖼️ Affiches (posters) | ✅ Gratuit (proxy intégré) |
|
||||
| 📺 Séries (liens 1fichier) | ✅ Gratuit (ZT / LocalDB) |
|
||||
| 🎮 Jeux / Logiciels / Ebooks | ✅ Gratuit (LocalDB uniquement) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
### Interface Web
|
||||
|
||||

|
||||
|
||||
### Qualités
|
||||
|
||||

|
||||
|
||||
|
||||
---
|
||||
## 🛠️ Installation
|
||||
|
||||
### 🐳 Via Docker (Recommandé)
|
||||
|
||||
C'est la méthode la plus simple pour garder un environnement propre. Nous utilisons désormais une image pré-construite qui se met à jour automatiquement.
|
||||
|
||||
```bash
|
||||
# 1. Cloner le projet (si ce n'est pas déjà fait)
|
||||
git clone https://gitlab.com/nonobzh22/hydr-hacked
|
||||
|
||||
# 2. Préparer la configuration
|
||||
cp .env.example .env
|
||||
|
||||
# 3. Lancer l'application
|
||||
docker compose up -d
|
||||
```
|
||||
📍 Accès : `http://localhost:3067`
|
||||
|
||||
> [!TIP]
|
||||
> L'application utilise l'image `registry.gitlab.com/nonobzh22/hydr-hacked:latest`. Elle est reconstruite automatiquement à chaque mise à jour, vous n'avez plus besoin de compiler localement.
|
||||
|
||||
---
|
||||
|
||||
### 💻 Installation Manuelle
|
||||
Pour ceux qui préfèrent une installation classique.
|
||||
|
||||
**Prérequis :** [Node.js](https://nodejs.org/) v20+
|
||||
|
||||
```bash
|
||||
# 1. Préparer la configuration
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Installer les dépendances
|
||||
npm install
|
||||
|
||||
# 3. Lancer l'application (compiler et démarrer)
|
||||
npm run build && npm start
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Si vous avez `make` installé, vous pouvez simplifier les commandes :
|
||||
> - `make start` : Installe, compile et lance l'application.
|
||||
> - `make dev` : Développement avec rechargement automatique (ou `npm run dev`).
|
||||
|
||||
|
||||
📍 Accès : `http://localhost:3067`
|
||||
|
||||
---
|
||||
|
||||
### ⚙️ Configuration (.env)
|
||||
|
||||
Créez un fichier `.env` à la racine du projet et configurez les variables suivantes :
|
||||
|
||||
| Variable | Type | Description |
|
||||
|---|---|---|
|
||||
| `ZT_URL` | **Requis** | URL complète du site ZT. |
|
||||
| `ZTNEWS_URL` | Optionnel | URL complète de la source ZTNews. |
|
||||
| `FT_URL` | Optionnel | URL complète de la source FreeTélécharger. |
|
||||
| `HYDRACKER_URL` | Optionnel | URL complète de votre instance Hydracker (nécessaire si plugin actif). |
|
||||
| `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. |
|
||||
| `PORT` | Optionnel | Port de l'application (Défaut : `3067`). |
|
||||
| `DB_PATH` | Optionnel | Chemin vers la base locale (Défaut : `./database/darkiworld.db`). |
|
||||
| `JD_HOST` | Optionnel | IP/Hôte de JDownloader. |
|
||||
| `JD_API_PORT` | Optionnel | Port API de JDownloader (Défaut : `3128`). |
|
||||
|
||||
> [!WARNING]
|
||||
> **Les URLs des sites sources** ne sont volontairement pas renseignées par défaut. Vous devez les remplir vous-même avec les URLs des sites sources respectifs.
|
||||
|
||||
> [!TIP]
|
||||
> **Comment obtenir ma `HYDRACKER_API_KEY` ?**
|
||||
> Connectez-vous sur votre instance Hydracker, cherchez la page **Paramètres du compte** et descendez jusqu'à **Jetons d'accès API**.
|
||||
> Cliquez sur **Créer un jeton** et copiez le token généré dans le champ `HYDRACKER_API_KEY` de votre `.env`.
|
||||
|
||||
|
||||
## 🧩 Créer un nouveau Plugin
|
||||
|
||||
L'architecture d'Hydr'Hacked est modulaire. Vous pouvez facilement ajouter une nouvelle source en créant un plugin qui implémente l'interface `ISource`.
|
||||
|
||||
### 1. Structure
|
||||
Créez un dossier dans `plugins/[NomDeVotreSource]/`. Vous aurez généralement besoin de :
|
||||
- `index.ts` : Point d'entrée et implémentation de la classe.
|
||||
- `api.ts` : Fonctions d'appels réseau.
|
||||
- `parser.ts` : Logique d'extraction des données (Cheerio, JSON, etc.).
|
||||
|
||||
### 2. Implémentation
|
||||
Votre classe doit implémenter `ISource` (`src/types/source.ts`) :
|
||||
|
||||
```typescript
|
||||
export interface ISource {
|
||||
name: string;
|
||||
healthCheck(): Promise<boolean>;
|
||||
search(query: string, mediaType?: MediaType): Promise<SearchResult[]>;
|
||||
getTrending(mediaType: MediaType): Promise<SearchResult[]>;
|
||||
getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData>;
|
||||
resolveLink?(linkId: string): Promise<string | null>; // Optionnel
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Enregistrement
|
||||
À la fin de votre fichier `index.ts`, enregistrez votre source :
|
||||
```typescript
|
||||
sourceRegistry.register(new VotrePluginAPI(CONFIG.VOTRE_URL));
|
||||
```
|
||||
|
||||
Le serveur découvrira et chargera automatiquement votre plugin au démarrage.
|
||||
|
||||
## 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
|
||||
**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.
|
||||
Chaque Pull Request est la bienvenue, tant qu'elle contribue à rendre l'accès encore plus fluide et... disons, "généreux".
|
||||
|
||||
## Note Liminaire
|
||||
Cet outil est une preuve de concept destinée à la recherche et à l'apprentissage. Son auteur ne cautionne aucun usage abusif ni aucune violation de droits tiers. Il appartient à chaque utilisateur de s'assurer que ses activités restent conformes à la législation ; la responsabilité de l'usage incombe exclusivement à l'utilisateur final.
|
||||
|
||||
## 📜 Licence
|
||||
Projet sous licence MIT. Faites-en bon usage (ou pas, on ne juge pas).
|
||||
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
hydrhacked:
|
||||
image: registry.gitlab.com/nonobzh22/hydr-hacked:main
|
||||
build: .
|
||||
container_name: hydrhacked_app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3067}:${PORT:-3067}"
|
||||
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./sessions:/app/sessions
|
||||
- ./downloads:/downloads
|
||||
- ./images:/app/images
|
||||
- ./database:/app/database
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1024M
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 MiB |
Generated
+1812
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "hydrhacked",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"description": "Hydr'Hacked - API Proxy and Frontend",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node --experimental-sqlite dist/src/index.js",
|
||||
"build": "tsc",
|
||||
"dev": "tsx --experimental-sqlite src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"ejs": "^5.0.2",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.2.0",
|
||||
"express-session": "^1.18.0",
|
||||
"helmet": "^7.1.0",
|
||||
"session-file-store": "^1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-session": "^1.19.0",
|
||||
"@types/node": "^25.6.1",
|
||||
"@types/session-file-store": "^1.2.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Appels réseau pour le plugin ZT.
|
||||
* Toutes les fonctions fetch sont ici ; le parsing reste dans parser.ts.
|
||||
*/
|
||||
|
||||
export async function fetchSearchResults(baseUrl: string, query: string): Promise<string> {
|
||||
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=0&q=${encodeURIComponent(query)}&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Accept': 'text/html, */*',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Referer': baseUrl
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchTrendingMovies(baseUrl: string): Promise<string> {
|
||||
const res = await fetch(`${baseUrl}/engine/ajax/controller.php?mod=filter&catid=3&q=&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchTrendingSeries(baseUrl: string): Promise<string> {
|
||||
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=15&q=&art=0&AiffchageMode=0&inputTirePar=1&cstart=0`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchContentPage(pageUrl: string): Promise<string> {
|
||||
const res = await fetch(pageUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchResolvedLink(zoneursUrl: string): Promise<string> {
|
||||
const url = zoneursUrl.startsWith('//') ? `https:${zoneursUrl}` : zoneursUrl;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3',
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} sur ${url}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchRecent(baseUrl: string): Promise<string> {
|
||||
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=55&q=&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { ISource, SearchResult, MediaType, ContentLinks, SelectionData } from '../../src/types/source.js';
|
||||
import { CONFIG } from '../../src/utils/config.js';
|
||||
import { sourceRegistry } from '../../src/core/registry.js';
|
||||
import { fetchSearchResults, fetchTrendingMovies, fetchTrendingSeries, fetchContentPage, fetchResolvedLink, fetchRecent } from './api.js';
|
||||
import { parseSearchHTML, parseContentHTML, extractLinkFromZtProtect } from './parser.js';
|
||||
|
||||
/**
|
||||
* Normalise un titre pour la comparaison (minuscules, sans accents, sans ponctuation).
|
||||
*/
|
||||
function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/-\s*saison\s*\d+/gi, '')
|
||||
.replace(/\(\s*\d{4}\s*\)/g, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Déduplique les résultats par titre normalisé, en gardant la première occurrence.
|
||||
*/
|
||||
function deduplicateByTitle(results: SearchResult[]): SearchResult[] {
|
||||
const seen = new Set<string>();
|
||||
return results.filter(r => {
|
||||
const key = normalizeTitle(r.title);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export class ZTAPI implements ISource {
|
||||
name = 'zt';
|
||||
displayName = 'ZT';
|
||||
private baseUrl: string | undefined;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!this.baseUrl) {
|
||||
console.warn('[ZT] ⚠️ ZT_URL non définie.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) throw new Error('ZT_URL non configurée.');
|
||||
if (!query || query.length < 4) throw new Error('La recherche nécessite au moins 4 caractères.');
|
||||
|
||||
const html = await fetchSearchResults(this.baseUrl, query);
|
||||
if (html.includes('Aucun résultat')) return [];
|
||||
|
||||
let results = parseSearchHTML(html, this.baseUrl);
|
||||
|
||||
if (mediaType === 'movie') {
|
||||
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
|
||||
} else {
|
||||
results = results.filter(r => r.type === 'series' || r.type === 'anime');
|
||||
}
|
||||
|
||||
return deduplicateByTitle(results);
|
||||
}
|
||||
|
||||
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) return [];
|
||||
try {
|
||||
const html = mediaType === 'movie'
|
||||
? await fetchTrendingMovies(this.baseUrl)
|
||||
: await fetchTrendingSeries(this.baseUrl);
|
||||
const results = parseSearchHTML(html, this.baseUrl).slice(0, 40);
|
||||
return deduplicateByTitle(results).slice(0, 20);
|
||||
} catch (e: any) {
|
||||
console.error(`[ZT] ❌ Erreur trending ${mediaType}:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getRecent(): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) return [];
|
||||
try {
|
||||
const html = await fetchRecent(this.baseUrl);
|
||||
const results = parseSearchHTML(html, this.baseUrl).slice(0, 40);
|
||||
return deduplicateByTitle(results).slice(0, 20);
|
||||
} catch (e: any) {
|
||||
console.error(`[ZT] ❌ Erreur getRecent:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getContentLinks(pageUrl: string): Promise<ContentLinks> {
|
||||
if (!this.baseUrl) throw new Error('ZT_URL non configurée.');
|
||||
const fullUrl = pageUrl.startsWith('http') ? pageUrl : (this.baseUrl + (pageUrl.startsWith('/') ? '' : '/') + pageUrl);
|
||||
const html = await fetchContentPage(fullUrl);
|
||||
return parseContentHTML(html);
|
||||
}
|
||||
|
||||
|
||||
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||
const targetUrl = seasonValue ? String(seasonValue) : identifier;
|
||||
const content = await this.getContentLinks(targetUrl);
|
||||
|
||||
const isSeries = targetUrl.includes('/telecharger-serie/') || targetUrl.includes('/serie-') || (content.relatedSeasons?.length || 0) > 0;
|
||||
|
||||
let currentSeasonLabel = "Saison (Actuelle)";
|
||||
if (content.releaseNames && content.releaseNames.length > 0) {
|
||||
const sm = content.releaseNames[0].match(/Saison\s*\d+/i);
|
||||
if (sm) currentSeasonLabel = sm[0];
|
||||
}
|
||||
|
||||
const formattedSeasons = (content.relatedSeasons || []).map(s => ({
|
||||
label: s.label,
|
||||
value: s.href
|
||||
}));
|
||||
|
||||
if (isSeries) {
|
||||
formattedSeasons.push({ label: currentSeasonLabel, value: targetUrl });
|
||||
formattedSeasons.sort((a, b) => {
|
||||
const numA = parseInt(a.label.replace(/\D/g, '')) || 0;
|
||||
const numB = parseInt(b.label.replace(/\D/g, '')) || 0;
|
||||
return numA - numB;
|
||||
});
|
||||
}
|
||||
|
||||
const allLinks = [...content.links];
|
||||
if (content.relatedQualities && content.relatedQualities.length > 0) {
|
||||
console.log(`[ZT] Fetching ${content.relatedQualities.length} other qualities concurrently...`);
|
||||
const qualityPromises = content.relatedQualities.map(async (q) => {
|
||||
try {
|
||||
const qContent = await this.getContentLinks(q.href);
|
||||
return qContent.links;
|
||||
} catch (e) {
|
||||
console.error(`[ZT] Error fetching quality page ${q.href}:`, e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const otherQualitiesLinks = await Promise.all(qualityPromises);
|
||||
otherQualitiesLinks.forEach(links => allLinks.push(...links));
|
||||
}
|
||||
|
||||
return { links: allLinks, seasons: formattedSeasons, isSeries };
|
||||
}
|
||||
|
||||
async resolveLink(linkId: string): Promise<string | null> {
|
||||
try {
|
||||
console.log(`[ZT] 🔓 Résolution du lien : ${linkId}`);
|
||||
const html = await fetchResolvedLink(linkId);
|
||||
const resolved = extractLinkFromZtProtect(html);
|
||||
if (!resolved) {
|
||||
console.warn(`[ZT] ⚠️ Impossible d'extraire le lien résolu du HTML de ZTProtect pour ${linkId}`);
|
||||
}
|
||||
return resolved;
|
||||
} catch (e: any) {
|
||||
console.error(`[ZT] ❌ Erreur resolveLink pour ${linkId}:`, e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-registration ──
|
||||
sourceRegistry.register(new ZTAPI(CONFIG.ZT_URL));
|
||||
@@ -0,0 +1,185 @@
|
||||
import { SearchResult, MediaType, ContentLinks, VideoLink } from '../../src/types/source.js';
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Parse le HTML de résultats de recherche ZT.
|
||||
*/
|
||||
export function parseSearchHTML(html: string, baseUrl: string | undefined): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
const coverRegex = /<div class="cover_global"[^>]*>([\s\S]*?)(?=<div class="cover_global"|$)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = coverRegex.exec(html)) !== null) {
|
||||
const block = match[1]!;
|
||||
|
||||
const titleMatch = block.match(/<div class="cover_infos_title"[^>]*>\s*<a href="([^"]+)"[^>]*>\s*([^<]+)/);
|
||||
if (!titleMatch) continue;
|
||||
|
||||
const href = titleMatch[1]!.trim();
|
||||
const title = titleMatch[2]!.trim();
|
||||
|
||||
const imgMatch = block.match(/<img class="mainimg"[^>]*src="([^"]+)"/);
|
||||
let image = imgMatch ? imgMatch[1]! : null;
|
||||
if (image && image.startsWith('/') && baseUrl) {
|
||||
image = baseUrl + image;
|
||||
}
|
||||
|
||||
let type: 'movie' | 'series' | 'anime' = 'movie';
|
||||
if (href.includes('/telecharger-serie/') || href.includes('/serie-')) {
|
||||
type = 'series';
|
||||
} else if (href.includes('/animes')) {
|
||||
type = 'anime';
|
||||
}
|
||||
|
||||
results.push({ title, image, hrefPath: href, year: null, type, source: 'zt' });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse le HTML d'une page de contenu ZT pour en extraire les liens et saisons.
|
||||
*/
|
||||
export function parseContentHTML(html: string): ContentLinks {
|
||||
const links: VideoLink[] = [];
|
||||
|
||||
const releaseNames: string[] = [];
|
||||
const releaseRegex = /<font color=red>([^<]+)<\/font>/g;
|
||||
let releaseMatch: RegExpExecArray | null;
|
||||
while ((releaseMatch = releaseRegex.exec(html)) !== null) {
|
||||
releaseNames.push(releaseMatch[1]!.trim());
|
||||
}
|
||||
|
||||
const sections = html.split(/<img src='\/img\/([^']+)'/);
|
||||
|
||||
for (let i = 1; i < sections.length; i += 2) {
|
||||
const hostImg = sections[i]!;
|
||||
const hostName = hostImg.replace('.png', '').replace('.jpg', '').replace('.webp', '');
|
||||
const sectionHtml = sections[i + 1] || '';
|
||||
|
||||
const linkRegex = /<a class="btnToLink"[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>/g;
|
||||
let linkMatch: RegExpExecArray | null;
|
||||
|
||||
while ((linkMatch = linkRegex.exec(sectionHtml)) !== null) {
|
||||
const zoneursUrl = linkMatch[1]!;
|
||||
const label = linkMatch[2]!.trim();
|
||||
|
||||
// Extraire la taille depuis le label : "NOM.FICHIER (11.5 GO)" → "11.5 GO"
|
||||
const sizeRegex = /\s*\(([\d.,]+\s*(?:go|gb|mo|mb|ko|kb|to|tb))\)/i;
|
||||
let sizeMatch = label.match(sizeRegex);
|
||||
let size = sizeMatch ? sizeMatch[1]!.trim().toUpperCase() : undefined;
|
||||
|
||||
// Si non trouvé dans le label, on cherche dans le nom de la release (qualité)
|
||||
if (!size && releaseNames.length > 0) {
|
||||
const qualityMatch = releaseNames[0].match(sizeRegex);
|
||||
if (qualityMatch) size = qualityMatch[1]!.trim().toUpperCase();
|
||||
}
|
||||
|
||||
// Nettoyer le label pour enlever la taille
|
||||
const cleanedLabel = label.replace(sizeRegex, "").trim();
|
||||
|
||||
// On n'utilise le label comme "épisode" que si c'est un vrai nom de fichier/épisode (pas juste "Télécharger")
|
||||
const isGenericLabel = /^(t\u00e9l\u00e9charger|download|cliquez ici|lien|turbobit|1fichier|uptobox|rapidgator|nitroflare|send.now)/i.test(cleanedLabel);
|
||||
let episode = (!isGenericLabel && cleanedLabel.length > 3) ? cleanedLabel : undefined;
|
||||
|
||||
// SI le label est générique, on cherche un texte juste avant (ex: "Episode 1")
|
||||
if (isGenericLabel || !episode) {
|
||||
const index = linkMatch.index;
|
||||
const prevHtml = sectionHtml.substring(Math.max(0, index - 100), index);
|
||||
// Cherche "Episode X", "Saison complète", etc.
|
||||
const epMatch = prevHtml.match(/(?:<b>|<strong>)?(Episode\s*\d+|Saison\s*compl\u00e8te)(?:<\/b>|<\/strong>)?/i);
|
||||
if (epMatch) {
|
||||
episode = epMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
let quality = releaseNames.length > 0 ? releaseNames[0] : 'Inconnu';
|
||||
if (quality.match(sizeRegex)) quality = quality.replace(sizeRegex, '');
|
||||
|
||||
let langs: string[] = [];
|
||||
let subs: string[] = [];
|
||||
|
||||
const textToScan = `${quality} ${cleanedLabel}`;
|
||||
const langMatch = textToScan.match(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi);
|
||||
if (langMatch) {
|
||||
const seenLangs = new Set<string>();
|
||||
const seenSubs = new Set<string>();
|
||||
langMatch.forEach(l => {
|
||||
const up = l.toUpperCase();
|
||||
if (up.includes('VOSTFR')) { seenLangs.add('VOSTFR'); seenSubs.add('French'); }
|
||||
else if (up.includes('TRUEFRENCH')) seenLangs.add('TrueFrench');
|
||||
else if (up.includes('FRENCH') || up === 'VF' || up === 'VFF') seenLangs.add('French');
|
||||
else if (up.includes('MULTI')) { seenLangs.add('MULTI'); seenSubs.add('Multi'); }
|
||||
});
|
||||
langs = Array.from(seenLangs);
|
||||
subs = Array.from(seenSubs);
|
||||
|
||||
quality = quality.replace(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi, '').trim();
|
||||
}
|
||||
|
||||
quality = quality.replace(/[\(\)\[\]\-]+$/g, '').replace(/[\(\)\[\]]/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (!quality || quality.toLowerCase() === 'inconnu') quality = 'WEB';
|
||||
|
||||
links.push({
|
||||
id: zoneursUrl,
|
||||
host: hostName,
|
||||
label: cleanedLabel,
|
||||
url: null,
|
||||
size,
|
||||
quality: quality,
|
||||
langs,
|
||||
subs,
|
||||
episode: episode,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const relatedSeasons: { href: string; label: string }[] = [];
|
||||
const relatedQualities: { href: string; label: string }[] = [];
|
||||
|
||||
// Chercher toutes les sections "également disponibles"
|
||||
const sectionRegex = /(Saisons?|Qualit(?:é|e)s?)\s*également disponibles[\s\S]*?<\/h3>([\s\S]*?)(?:<h3|<\/div>|<div[^>]*class="postinfo")/gi;
|
||||
let sSectionMatch: RegExpExecArray | null;
|
||||
while ((sSectionMatch = sectionRegex.exec(html)) !== null) {
|
||||
const type = sSectionMatch[1].toLowerCase();
|
||||
const seasonBlock = sSectionMatch[2]!;
|
||||
const seasonRegex = /<a[^>]*href="([^"]+)"[^>]*><span class="otherquality">([\s\S]*?)<\/span><\/a>/g;
|
||||
let sMatch: RegExpExecArray | null;
|
||||
while ((sMatch = seasonRegex.exec(seasonBlock)) !== null) {
|
||||
const label = sMatch[2]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
const href = sMatch[1]!.trim();
|
||||
if (type.includes('saison')) {
|
||||
if (!relatedSeasons.find(rs => rs.href === href)) {
|
||||
relatedSeasons.push({ href, label });
|
||||
}
|
||||
} else {
|
||||
if (!relatedQualities.find(rs => rs.href === href)) {
|
||||
relatedQualities.push({ href, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { links, releaseNames, relatedSeasons, relatedQualities };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le lien final déverrouillé de la page HTML de ZTPROTECT.
|
||||
*/
|
||||
export function extractLinkFromZtProtect(html: string): string | null {
|
||||
// 1. Essayer de trouver la valeur de l'input result-input
|
||||
let match = html.match(/class="result-input"\s+value="([^"]+)"/i);
|
||||
if (match && match[1]) return match[1];
|
||||
|
||||
// 2. Essayer de trouver l'attribut href du bouton de succès
|
||||
match = html.match(/<a\s+[^>]*href="([^"]+)"[^>]*class="[^"]*btn-success[^"]*"/i);
|
||||
if (match && match[1]) return match[1];
|
||||
|
||||
match = html.match(/class="[^"]*btn-success[^"]*"\s+[^>]*href="([^"]+)"/i);
|
||||
if (match && match[1]) return match[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Appels réseau pour free-telecharger.cam.
|
||||
* Pas de challenge CF actif, fetch direct simple.
|
||||
*/
|
||||
|
||||
const TIMEOUT = 20_000;
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0';
|
||||
|
||||
async function ftGet(url: string): Promise<string> {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchSearch(baseUrl: string, query: string): Promise<string> {
|
||||
return ftGet(`${baseUrl}/1/recherche1/1.html?rech_fiche=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
export async function fetchTrending(baseUrl: string): Promise<string> {
|
||||
return ftGet(`${baseUrl}/page/1.html`);
|
||||
}
|
||||
|
||||
export async function fetchPage(pageUrl: string): Promise<string> {
|
||||
return ftGet(pageUrl);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ISource, SearchResult, MediaType, SelectionData, ContentLinks } from '../../src/types/source.js';
|
||||
import { CONFIG } from '../../src/utils/config.js';
|
||||
import { sourceRegistry } from '../../src/core/registry.js';
|
||||
import { fetchSearch, fetchTrending, fetchPage } from './api.js';
|
||||
import { parseSearchResults, parseTrendingResults, parseContentHTML, parseEpisodeLinks, parseOtherVersions } from './parser.js';
|
||||
|
||||
function isSeriesIdentifier(identifier: string): boolean {
|
||||
return /saison|pack-series|series-(vf|vostfr|terminee)/i.test(identifier);
|
||||
}
|
||||
|
||||
export class FreeTeleAPI implements ISource {
|
||||
name = 'freetel';
|
||||
displayName = 'Free-Télécharger';
|
||||
private baseUrl: string | undefined;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl?.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!this.baseUrl) {
|
||||
console.warn('[FreeTel] ⚠️ FT_URL non définie.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(this.baseUrl, {
|
||||
method: 'HEAD',
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return true; // tolérant : le test réel se fait au premier scrape
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
|
||||
if (!query || query.length < 3) throw new Error('La recherche nécessite au moins 3 caractères.');
|
||||
const html = await fetchSearch(this.baseUrl, query);
|
||||
let results = parseSearchResults(html, this.baseUrl);
|
||||
if (mediaType === 'movie') {
|
||||
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
|
||||
} else {
|
||||
results = results.filter(r => r.type === 'series' || r.type === 'anime');
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) return [];
|
||||
try {
|
||||
const html = await fetchTrending(this.baseUrl);
|
||||
let results = parseTrendingResults(html, this.baseUrl);
|
||||
if (mediaType === 'movie') {
|
||||
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
|
||||
} else {
|
||||
results = results.filter(r => r.type === 'series' || r.type === 'anime');
|
||||
}
|
||||
return results.slice(0, 20);
|
||||
} catch (e: any) {
|
||||
console.error(`[FreeTel] Erreur trending ${mediaType}:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getRecent(): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) return [];
|
||||
try {
|
||||
const html = await fetchTrending(this.baseUrl);
|
||||
const results = parseTrendingResults(html, this.baseUrl).slice(0, 20);
|
||||
return results;
|
||||
} catch (e: any) {
|
||||
console.error(`[FreeTel] Erreur getRecent:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getContentLinks(identifier: string): Promise<ContentLinks> {
|
||||
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
|
||||
const url = identifier.startsWith('http') ? identifier : `${this.baseUrl}/${identifier.replace(/^\//, '')}`;
|
||||
const html = await fetchPage(url);
|
||||
return parseContentHTML(html, isSeriesIdentifier(identifier));
|
||||
}
|
||||
|
||||
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
|
||||
// Si seasonValue est fournie (l'UI a cliqué sur une autre qualité), on switch de fiche
|
||||
const targetIdentifier = seasonValue ? String(seasonValue) : identifier;
|
||||
const url = targetIdentifier.startsWith('http') ? targetIdentifier : `${this.baseUrl}/${targetIdentifier.replace(/^\//, '')}`;
|
||||
const html = await fetchPage(url);
|
||||
const isSeries = isSeriesIdentifier(targetIdentifier);
|
||||
const content = parseContentHTML(html, isSeries);
|
||||
|
||||
// Pour les films, exposer les autres qualités comme "seasons" (l'UI les affichera en dropdown)
|
||||
let seasons: { label: string; value: string }[] = [];
|
||||
if (!isSeries) {
|
||||
seasons = parseOtherVersions(html, this.baseUrl);
|
||||
// Ajouter la version courante comme première entrée (sélectionnée par défaut)
|
||||
const currentQuality = content.links[0]?.quality;
|
||||
if (currentQuality && currentQuality !== 'Inconnu') {
|
||||
seasons.unshift({ label: currentQuality, value: targetIdentifier });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
links: content.links,
|
||||
seasons,
|
||||
isSeries,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveLink(linkId: string): Promise<string | null> {
|
||||
let hostUrl: string | null = null;
|
||||
|
||||
// Cas série : page intermédiaire liens.free-telecharger.cam/SLUG-episode_N
|
||||
if (linkId.includes('liens.free-telecharger.cam')) {
|
||||
try {
|
||||
const html = await fetchPage(linkId);
|
||||
const hosts = parseEpisodeLinks(html);
|
||||
if (hosts.length === 0) {
|
||||
console.warn(`[FreeTel] Aucun hôte trouvé sur ${linkId}`);
|
||||
return null;
|
||||
}
|
||||
const preferred = hosts.find(h => /1fichier/i.test(h.host))
|
||||
|| hosts.find(h => /turbobit/i.test(h.host))
|
||||
|| hosts[0];
|
||||
hostUrl = preferred ? preferred.url : null;
|
||||
} catch (e: any) {
|
||||
console.error(`[FreeTel] Erreur resolveLink:`, e.message);
|
||||
return null;
|
||||
}
|
||||
} else if (linkId.startsWith('http')) {
|
||||
// Cas film : linkId est déjà l'URL hôte (1fichier, Turbobit, …)
|
||||
hostUrl = linkId;
|
||||
}
|
||||
|
||||
return hostUrl;
|
||||
}
|
||||
}
|
||||
|
||||
sourceRegistry.register(new FreeTeleAPI(CONFIG.FT_URL));
|
||||
@@ -0,0 +1,193 @@
|
||||
import { SearchResult, ContentLinks, VideoLink } from '../../src/types/source.js';
|
||||
|
||||
interface FilmMetadata {
|
||||
quality?: string;
|
||||
size?: string;
|
||||
langs?: string[];
|
||||
}
|
||||
|
||||
function parseFilmMetadata(html: string): FilmMetadata {
|
||||
const meta: FilmMetadata = {};
|
||||
const q = html.match(/Qualit[ée][^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
|
||||
if (q) meta.quality = q[1]!.trim();
|
||||
const t = html.match(/Taille[^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
|
||||
if (t) meta.size = t[1]!.trim();
|
||||
const l = html.match(/Langue[^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
|
||||
if (l) meta.langs = l[1]!.trim().split(/[,\/]/).map(s => s.trim()).filter(Boolean);
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les autres versions/qualités disponibles pour le même film.
|
||||
* Section "Autres versions disponibles pour ..."
|
||||
*/
|
||||
export function parseOtherVersions(html: string, baseUrl: string): { label: string; value: string }[] {
|
||||
const out: { label: string; value: string }[] = [];
|
||||
const sectionMatch = html.match(/Autres versions disponibles[\s\S]+?<\/div>\s*<\/div>/i);
|
||||
if (!sectionMatch) return out;
|
||||
const linkRegex = /<a\s+href="([^"]+)"[\s\S]*?🎞️\s*([^<]+?)<\/a>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRegex.exec(sectionMatch[0])) !== null) {
|
||||
const href = absUrl(m[1]!, baseUrl);
|
||||
const label = m[2]!.replace(/\s+/g, ' ').trim();
|
||||
if (!out.find(o => o.value === href)) out.push({ label, value: href });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/\b(web-?dl|web-?rip|blu-?ray|full-?blu-?ray|hdtv|hdrip|dvdrip|bdrip|hdlight|ultra-?hdlight|truefrench|french|multi(?:langues?)?|vff|vfq|vfi|vf|vostfr|english|hdts|cam|ts|r5|dvdscr|x264|x265|h\.?264|h\.?265|hevc)\b/g, '')
|
||||
.replace(/\b(720p|1080p|2160p|4k|uhd|3d|sd|hd)\b/g, '')
|
||||
.replace(/\(\s*\d{4}\s*\)/g, '')
|
||||
.replace(/-\s*saison\s*\d+/gi, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function deduplicateByTitle<T extends { title: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(it => {
|
||||
const k = normalizeTitle(it.title);
|
||||
if (!k || seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function detectType(href: string): 'movie' | 'series' | 'anime' {
|
||||
if (/saison|pack-series|series-(vf|vostfr|terminee)/i.test(href)) return 'series';
|
||||
if (/animes?/i.test(href)) return 'anime';
|
||||
return 'movie';
|
||||
}
|
||||
|
||||
function absUrl(url: string, baseUrl: string): string {
|
||||
if (url.startsWith('http')) return url;
|
||||
const cleanedBase = baseUrl.replace(/\/$/, '');
|
||||
return cleanedBase + '/' + url.replace(/^\//, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format résultats de recherche : <div class="image-container"><img/></div>
|
||||
* puis <div class="content"><div class="titre1"><A href="...">Titre</A></div>
|
||||
*/
|
||||
export function parseSearchResults(html: string, baseUrl: string): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
const blockRegex = /<div\s+class="image-container">\s*<img[^>]+src="([^"]+)"[^>]*>[\s\S]*?<div\s+class="titre1">\s*<a\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = blockRegex.exec(html)) !== null) {
|
||||
const image = absUrl(m[1]!, baseUrl);
|
||||
const hrefRaw = m[2]!;
|
||||
const href = absUrl(hrefRaw, baseUrl);
|
||||
const title = m[3]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (!title) continue;
|
||||
results.push({
|
||||
title,
|
||||
year: null,
|
||||
image,
|
||||
hrefPath: href,
|
||||
type: detectType(hrefRaw),
|
||||
source: 'freetel',
|
||||
});
|
||||
}
|
||||
return deduplicateByTitle(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format nouveautés (/page/1.html) : <a href="..." data-tip-b64="..."><img alt="Titre" src="..."/></a>
|
||||
*/
|
||||
export function parseTrendingResults(html: string, baseUrl: string): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
const blockRegex = /<a\s+href="((?:films?-|saison-|pack-series|series-)[^"]+\.html)"[^>]*data-tip-b64="[^"]+"[^>]*>\s*<img\s+alt="([^"]+)"[^>]+src="([^"]+)"/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = blockRegex.exec(html)) !== null) {
|
||||
const hrefRaw = m[1]!;
|
||||
const title = m[2]!.trim();
|
||||
const image = absUrl(m[3]!, baseUrl);
|
||||
results.push({
|
||||
title,
|
||||
year: null,
|
||||
image,
|
||||
hrefPath: absUrl(hrefRaw, baseUrl),
|
||||
type: detectType(hrefRaw),
|
||||
source: 'freetel',
|
||||
});
|
||||
}
|
||||
return deduplicateByTitle(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse une fiche (film ou série).
|
||||
* - Film : <input name="lien" value="https://turbobit.net/..."> dans la section #link, précédé d'un <p>HOST</p>
|
||||
* - Série : <input name="lien" value="https://liens.free-telecharger.cam/SLUG-episode_N"> (à résoudre via resolveLink)
|
||||
*/
|
||||
export function parseContentHTML(html: string, isSeries: boolean): ContentLinks {
|
||||
const links: VideoLink[] = [];
|
||||
|
||||
if (isSeries) {
|
||||
const episodeRegex = /<input[^>]+name="lien"\s+value="(https?:\/\/liens\.free-telecharger\.cam\/[^"]+)"/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
let idx = 0;
|
||||
while ((m = episodeRegex.exec(html)) !== null) {
|
||||
const url = m[1]!;
|
||||
const epMatch = url.match(/episode_(\d+|final|complet)/i);
|
||||
const episode = epMatch ? epMatch[1] : null;
|
||||
links.push({
|
||||
id: url,
|
||||
host: 'multi',
|
||||
label: episode ? `Épisode ${episode}` : `Lien ${idx + 1}`,
|
||||
episode: episode || undefined,
|
||||
quality: 'multi',
|
||||
url: null,
|
||||
});
|
||||
idx++;
|
||||
}
|
||||
} else {
|
||||
// Films : section #link contient des blocs (Host name dans <p>, URL dans <input hidden lien>)
|
||||
const meta = parseFilmMetadata(html);
|
||||
const sectionMatch = html.match(/<div\s+id="link"[\s\S]+/);
|
||||
const sec = sectionMatch ? sectionMatch[0] : html;
|
||||
const pairRegex = /<p[^>]*>\s*([A-Za-z0-9-]+)\s*<\/p>[\s\S]{0,800}?<input[^>]+name="lien"\s+value="([^"]+)"/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pairRegex.exec(sec)) !== null) {
|
||||
const host = m[1]!.trim();
|
||||
const url = m[2]!;
|
||||
if (/free-telecharger|trustzone|get-trust-zone/i.test(url)) continue;
|
||||
links.push({
|
||||
id: url,
|
||||
host: host.toLowerCase(),
|
||||
label: host,
|
||||
quality: meta.quality || 'Inconnu',
|
||||
size: meta.size,
|
||||
langs: meta.langs,
|
||||
url: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { links };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse la page intermédiaire d'un épisode (liens.free-telecharger.cam/...).
|
||||
* Structure : <table class="gridtable"> avec <tr> contenant [HOST] et <a href="URL">.
|
||||
*/
|
||||
export function parseEpisodeLinks(html: string): { host: string; url: string }[] {
|
||||
const out: { host: string; url: string }[] = [];
|
||||
const tableMatch = html.match(/<table[^>]*class="gridtable"[\s\S]*?<\/table>/i);
|
||||
if (!tableMatch) return out;
|
||||
const rows = tableMatch[0].match(/<tr[\s\S]*?<\/tr>/gi) || [];
|
||||
for (const row of rows) {
|
||||
const hostMatch = row.match(/\[([^\]]+)\]/);
|
||||
const aMatch = row.match(/<a\s+[^>]*href\s*=\s*["']?([^"'\s>]+)/i);
|
||||
if (hostMatch && aMatch) {
|
||||
out.push({
|
||||
host: hostMatch[1]!.toLowerCase().trim(),
|
||||
url: aMatch[1]!.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { CONFIG } from '../../src/utils/config.js';
|
||||
|
||||
export const CONFIG_HYDRACKER = {
|
||||
BASE_URL: (CONFIG.HYDRACKER_URL || '').replace(/\/$/, ''), // Supprime le slash final
|
||||
API_KEY: CONFIG.HYDRACKER_API_KEY,
|
||||
TIMEOUT: CONFIG.HYDRACKER_TIMEOUT || 15000,
|
||||
};
|
||||
|
||||
const HYDRACKER_HEADERS = {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${CONFIG_HYDRACKER.API_KEY}`,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
};
|
||||
|
||||
const TIMEOUT = CONFIG_HYDRACKER.TIMEOUT; // 30 secondes par défaut (configurable)
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
maxRetries: number = 2,
|
||||
initialDelay: number = 2000
|
||||
): Promise<Response> {
|
||||
let attempt = 0;
|
||||
let delay = initialDelay;
|
||||
|
||||
while (true) {
|
||||
attempt++;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (res.status === 502 || res.status === 503 || res.status === 504 || res.status === 429) {
|
||||
if (attempt < maxRetries) {
|
||||
console.warn(`[Hydracker-API] Attempt ${attempt}/${maxRetries} returned HTTP ${res.status} on fetch. Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay *= 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
clearTimeout(timeoutId);
|
||||
const isTimeout = err.name === 'AbortError' || err.message?.includes('aborted');
|
||||
if (attempt < maxRetries) {
|
||||
const waitTime = isTimeout ? 1000 : delay;
|
||||
console.warn(`[Hydracker-API] Attempt ${attempt}/${maxRetries} failed/timed out (${err.message}). Retrying in ${waitTime}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
if (!isTimeout) delay *= 2;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiGet(urlPath: string, params: Record<string, any> = {}) {
|
||||
const qs = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
|
||||
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/${urlPath}` + (qs ? `?${qs}` : '');
|
||||
try {
|
||||
const res = await fetchWithRetry(url, {
|
||||
headers: HYDRACKER_HEADERS
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`[Hydracker-API] apiGet HTTP ${res.status} on ${urlPath}`);
|
||||
return null;
|
||||
}
|
||||
return await res.json();
|
||||
} catch (e: any) {
|
||||
console.error(`[Hydracker-API] apiGet Error on ${urlPath}:`, e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiPost(urlPath: string, body: any = {}) {
|
||||
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/${urlPath}`;
|
||||
try {
|
||||
const res = await fetchWithRetry(url, {
|
||||
method: 'POST',
|
||||
headers: { ...HYDRACKER_HEADERS, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return { status: res.status, body: await res.text() };
|
||||
} catch (e: any) {
|
||||
console.error(`[Hydracker-API] apiPost Error on ${urlPath}:`, e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSearch(query: string) {
|
||||
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/search/${encodeURIComponent(query)}?loader=searchAutocomplete`;
|
||||
try {
|
||||
const res = await fetchWithRetry(url, {
|
||||
headers: HYDRACKER_HEADERS
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`[Hydracker-API] Search HTTP ${res.status} for "${query}"`);
|
||||
return null;
|
||||
}
|
||||
return await res.json();
|
||||
} catch (e: any) {
|
||||
console.error('[Hydracker-API] Search failed:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMovieLinks(titleId: string) {
|
||||
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/titles/${titleId}/download`;
|
||||
try {
|
||||
const res = await fetchWithRetry(url, {
|
||||
headers: HYDRACKER_HEADERS
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch (e: any) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSeriesLiens(titleId: string, season: number = 1) {
|
||||
const allLiens: any[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const result = await apiGet('liens', {
|
||||
title_id: titleId, loader: 'linksdl', season,
|
||||
perPage: 500, page, filters: '', paginate: 'lengthAware'
|
||||
});
|
||||
if (!result || result.error) break;
|
||||
const pagination = result.pagination || {};
|
||||
const data = pagination.data || [];
|
||||
if (!data.length) break;
|
||||
allLiens.push(...data);
|
||||
const lastPage = pagination.last_page || pagination.lastPage || 1;
|
||||
if (page >= lastPage) break;
|
||||
page++;
|
||||
}
|
||||
return allLiens;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { ISource, SearchResult, MediaType, ContentLinks, VideoLink, SelectionData } from '../../src/types/source.js';
|
||||
import { sourceRegistry } from '../../src/core/registry.js';
|
||||
import { CONFIG_HYDRACKER, apiGet, apiPost, fetchSearch, fetchMovieLinks, fetchSeriesLiens } from './api.js';
|
||||
import {
|
||||
QUALITY_MAP, formatSize,
|
||||
parseSearchResults, parseTrendingResults,
|
||||
parseMovieLinks, parseSeasons, parsePremiumLink,
|
||||
getLangs, getSubs
|
||||
} from './parser.js';
|
||||
|
||||
export class HydrackerAPI implements ISource {
|
||||
name = 'hydracker';
|
||||
displayName = 'Hydracker (Token)';
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!CONFIG_HYDRACKER.BASE_URL || !CONFIG_HYDRACKER.API_KEY) {
|
||||
console.warn('[Hydracker] ⚠️ HYDRACKER_URL ou HYDRACKER_API_KEY manquante.');
|
||||
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[]> {
|
||||
const data = await fetchSearch(query);
|
||||
if (!data) {
|
||||
console.error('[Hydracker] search: fetchSearch a retourné null pour', query);
|
||||
return [];
|
||||
}
|
||||
const totalRaw = (data.results || []).length;
|
||||
const parsed = parseSearchResults(data, mediaType);
|
||||
console.log(`[Hydracker] search "${query}" (${mediaType}): ${totalRaw} résultats bruts → ${parsed.length} après filtre`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||
const type = mediaType === 'series' ? 'series' : 'movie';
|
||||
try {
|
||||
const data = await apiGet('titles', { order: 'trending:desc', type, page: 1, paginate: 'lengthAware' });
|
||||
return parseTrendingResults(data);
|
||||
} catch (e: any) {
|
||||
console.error(`[Hydracker] getTrending Error for ${type}:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getRecent(): Promise<SearchResult[]> {
|
||||
try {
|
||||
const data = await apiGet('titles', { order: 'created_at:desc', page: 1, paginate: 'lengthAware' });
|
||||
return parseTrendingResults(data);
|
||||
} catch (e: any) {
|
||||
console.error(`[Hydracker] getRecent Error:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||
const seasonsList = await this.getSeasons(identifier);
|
||||
|
||||
let isSeries = false;
|
||||
if (type) {
|
||||
isSeries = (type === 'series' || type === 'serie' || type === 'tv');
|
||||
} else {
|
||||
isSeries = seasonsList.length > 0;
|
||||
}
|
||||
|
||||
const currentSeason = seasonValue ? parseInt(String(seasonValue), 10) : 1;
|
||||
const content = await this.getContentLinks(identifier, currentSeason);
|
||||
const formattedSeasons = seasonsList.map(num => ({ label: `Saison ${num}`, value: num }));
|
||||
|
||||
return {
|
||||
links: content.links,
|
||||
seasons: isSeries ? formattedSeasons : [],
|
||||
isSeries
|
||||
};
|
||||
}
|
||||
|
||||
async getContentLinks(titleId: string, season: number = 1): Promise<ContentLinks> {
|
||||
// Essai film en premier
|
||||
const movieData = await fetchMovieLinks(titleId);
|
||||
if (movieData) {
|
||||
const movieLinks = parseMovieLinks(movieData);
|
||||
if (movieLinks.length > 0) return { links: movieLinks };
|
||||
}
|
||||
|
||||
// Fallback série
|
||||
const rawLiens = await fetchSeriesLiens(titleId, season);
|
||||
const links: VideoLink[] = rawLiens.map(l => ({
|
||||
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 };
|
||||
}
|
||||
|
||||
async getSeasons(titleId: string): Promise<number[]> {
|
||||
const result = await apiGet(`titles/${titleId}/seasons`);
|
||||
return parseSeasons(result);
|
||||
}
|
||||
|
||||
private isPremiumCache: boolean | null = null;
|
||||
private premiumCheckPromise: Promise<boolean> | null = null;
|
||||
|
||||
async checkPremiumStatus(): Promise<boolean> {
|
||||
if (this.isPremiumCache !== null) return this.isPremiumCache;
|
||||
if (this.premiumCheckPromise) return this.premiumCheckPromise;
|
||||
|
||||
this.premiumCheckPromise = (async () => {
|
||||
try {
|
||||
const result = await apiGet('users/me');
|
||||
if (result && result.user) {
|
||||
this.isPremiumCache = !!result.user.IsPremium;
|
||||
console.log(`[Hydracker] Statut Premium vérifié: ${this.isPremiumCache ? 'OUI' : 'NON'}`);
|
||||
return this.isPremiumCache;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[Hydracker] Erreur vérification Premium:', e.message);
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
return await this.premiumCheckPromise;
|
||||
}
|
||||
|
||||
async resolveLink(linkId: string): Promise<string | null> {
|
||||
// Tentative de résolution via la base locale d'abord
|
||||
const localDbSource = sourceRegistry.get('localdb') as any;
|
||||
if (localDbSource && typeof localDbSource.resolveLocalLink === 'function') {
|
||||
const localUrl = localDbSource.resolveLocalLink(linkId);
|
||||
if (localUrl) {
|
||||
console.log(`[Hydracker] Lien résolu via base de données locale (ID: ${linkId})`);
|
||||
return localUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const isPremium = await this.checkPremiumStatus();
|
||||
|
||||
if (!isPremium) {
|
||||
console.log(`[Hydracker] Compte non Premium détecté. Bypass de Hydracker, passage direct à Movix...`);
|
||||
return await this.resolveMovixLink(linkId);
|
||||
}
|
||||
|
||||
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;
|
||||
} catch (e: any) {
|
||||
console.error(`[Hydracker] Exception resolving lien ${linkId} (attempt ${attempt}):`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Hydracker] Échec de la résolution classique (Erreur). Fallback automatique via Movix...`);
|
||||
return await this.resolveMovixLink(linkId);
|
||||
}
|
||||
|
||||
async resolveMovixLink(lienId: string, titleId?: string): Promise<string | null> {
|
||||
try {
|
||||
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 response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Referer': 'https://movix.cloud/',
|
||||
'Origin': 'https://movix.cloud',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || data.success === false) {
|
||||
console.error('[Hydracker] Erreur API Movix:', data.error || 'Erreur inconnue');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Récupération du lien direct selon le format de réponse Movix
|
||||
const directUrl = data.directDL || data.direct_url ||
|
||||
(data.embed_url && (data.embed_url.directDL || data.embed_url.src || data.embed_url.lien));
|
||||
|
||||
if (directUrl) {
|
||||
console.log(`[Hydracker] Movix a résolu le lien avec succès !`);
|
||||
return directUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e: any) {
|
||||
console.error(`[Hydracker] Exception lors de la résolution Movix :`, e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-registration ──
|
||||
sourceRegistry.register(new HydrackerAPI());
|
||||
@@ -0,0 +1,167 @@
|
||||
import { SearchResult, MediaType, VideoLink } from '../../src/types/source.js';
|
||||
|
||||
export const QUALITY_MAP: Record<number, string> = {
|
||||
89: "REMUX UHD", 57: "REMUX BLURAY", 92: "REMUX DVD",
|
||||
17: "Blu-Ray 1080p", 76: "Blu-Ray 1080p (x265)", 16: "Blu-Ray 720p", 18: "Blu-Ray 3D",
|
||||
52: "HD 1080p", 31: "HD 720p",
|
||||
50: "HDLight 1080p", 86: "HDLight 1080p (x265)", 49: "HDLight 720p",
|
||||
60: "Ultra HDLight (x265)", 53: "ULTRA HD (x265)",
|
||||
55: "WEB 1080p", 83: "WEB 1080p (x265)", 94: "WEB 1080p Light", 54: "WEB 720p", 4: "WEB",
|
||||
62: "HDTV 1080p", 61: "HDTV 720p", 14: "HDTV",
|
||||
15: "HDRip", 1: "DVDRIP", 51: "DVDRIP MKV",
|
||||
13: "ISO", 12: "IMG", 10: "DVD-R", 11: "Full-DVD",
|
||||
};
|
||||
|
||||
export const LANGUAGE_MAP: Record<number, string> = {
|
||||
1: "MULTI", 2: "Arab", 3: "Bengali", 4: "Chinese", 5: "English", 6: "French", 7: "French (Canada)",
|
||||
8: "TrueFrench", 9: "German", 10: "Hindi", 11: "Italian", 12: "Japanese", 13: "Korean",
|
||||
14: "Mandarin", 15: "Portuguese", 16: "Russian", 17: "Spanish", 18: "Turkish", 19: "unknown",
|
||||
23: "Danish", 28: "Finnish", 33: "Swedish", 35: "Bulgarian", 40: "Dutch", 41: "Persian",
|
||||
42: "Indonesian", 43: "Hebrew", 44: "Thai", 49: "Czech", 53: "Albanian", 57: "Greek",
|
||||
61: "Hungarian", 65: "Malaysian", 66: "Norwegian", 68: "Polish", 71: "Lithuanian",
|
||||
78: "Croatian", 84: "Malay", 90: "Romanian", 96: "Ukrainian", 102: "Vietnamese",
|
||||
105: "Sámegiella", 106: "Muet", 108: "Georgian", 110: "Nigerian", 113: "Maasai",
|
||||
117: "Estonian", 120: "Serbian", 123: "Slovak", 124: "Slovenian", 125: "Amharic",
|
||||
126: "Belarusian", 127: "Bosnian", 128: "Burmese", 129: "Dzongkha", 137: "Icelandic",
|
||||
138: "Kazakh", 139: "Kurdish", 140: "Latin", 141: "Latvian", 142: "Macedonian", 143: "Maori",
|
||||
144: "Mongolian", 145: "Norwegian Bokmål", 146: "Serbo-Croatian", 148: "Tagalog", 149: "Tibetan",
|
||||
150: "Walloon", 151: "Wolof", 152: "Yoruba", 154: "Moore", 155: "Quechuan", 156: "Rwanda",
|
||||
160: "Filipino", 161: "VO", 165: "Afrikaans", 171: "Créole", 174: "Gujarati", 175: "Cantonese",
|
||||
177: "FRENCH AD"
|
||||
};
|
||||
|
||||
export const SUB_MAP: Record<number, string> = {
|
||||
1: "Arab", 2: "Bengali", 3: "Chinese", 4: "English", 5: "French", 6: "German", 7: "Hindi",
|
||||
8: "Italian", 9: "Japanese", 10: "Korean", 11: "Mandarin", 12: "Portuguese", 13: "Russian",
|
||||
14: "Spanish", 15: "Turkish", 16: "Inconnu", 17: "Multi", 23: "Danish", 28: "Finnish",
|
||||
33: "Swedish", 35: "Bulgare", 36: "Persian", 37: "Hebrew", 40: "Dutch", 42: "Indonesian",
|
||||
50: "Thai", 53: "Greek", 61: "Hungarian", 65: "Malaysian", 66: "Norwegian", 68: "Polish",
|
||||
71: "Lithuanian", 76: "Czech", 82: "Croatian", 88: "Malay", 94: "Romanian", 100: "Ukrainian",
|
||||
106: "Vietnamese", 112: "Sámegiella", 115: "Estonian", 120: "Serbian", 123: "Slovak",
|
||||
127: "Slovenian", 128: "Afrikaans", 129: "Albanian", 130: "Amharic", 131: "Armenian",
|
||||
132: "Azerbaijani", 133: "Basque", 134: "Belarusian", 135: "Bosnian", 136: "Catalan",
|
||||
137: "Cebuano", 138: "Chichewa", 139: "Corsican", 140: "Esperanto", 141: "Frisian",
|
||||
142: "Galician", 143: "Georgian", 144: "Gujarati", 145: "Haitian Creole", 146: "Hausa",
|
||||
147: "Hawaiian", 148: "Icelandic", 149: "Igbo", 150: "Irish", 151: "Javanese", 152: "Kannada",
|
||||
153: "Kazakh", 154: "Khmer", 155: "Kurdish", 156: "Kyrgyz", 157: "Lao", 158: "Latin",
|
||||
159: "Latvian", 160: "Luxembourgish", 161: "Macedonian", 162: "Malagasy", 163: "Maltese",
|
||||
164: "Maori", 165: "Marathi", 166: "Mongolian", 167: "Myanmar", 168: "Nepali", 169: "Pashto",
|
||||
170: "Punjabi", 171: "Sindhi", 172: "Sinhala", 173: "Somali", 174: "Swahili", 175: "Tajik",
|
||||
176: "Tamil", 177: "Telugu", 178: "Uzbek", 179: "Welsh", 180: "Xhosa", 181: "Yiddish",
|
||||
182: "Yoruba", 183: "Zulu", 184: "Filipino"
|
||||
};
|
||||
|
||||
export function getLangs(l: any): string[] {
|
||||
if (l.langues_compact && l.langues_compact.length) {
|
||||
return l.langues_compact.map((la: any) => la.name || '');
|
||||
}
|
||||
if (l.langues && Array.isArray(l.langues)) {
|
||||
return l.langues.map((id: any) => LANGUAGE_MAP[id] || '');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getSubs(l: any): string[] {
|
||||
if (l.subs_compact && l.subs_compact.length) {
|
||||
return l.subs_compact.map((la: any) => la.name || '');
|
||||
}
|
||||
if (l.subs && Array.isArray(l.subs)) {
|
||||
return l.subs.map((id: any) => SUB_MAP[id] || '');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return 'N/A';
|
||||
const gb = bytes / (1024 ** 3);
|
||||
if (gb >= 1) return `${gb.toFixed(2)} Go`;
|
||||
const mb = bytes / (1024 ** 2);
|
||||
return `${mb.toFixed(0)} Mo`;
|
||||
}
|
||||
|
||||
export function parseSearchResults(data: any, mediaType: MediaType): SearchResult[] {
|
||||
const EXCLUDED_TYPES = ['games', 'music', 'app', 'ebook', 'emulation'];
|
||||
// Accepte à la fois les entrées avec model_type === 'title' et celles sans ce champ
|
||||
const results = (data.results || []).filter((r: any) =>
|
||||
(!r.model_type || r.model_type === 'title') &&
|
||||
!EXCLUDED_TYPES.includes((r.type || '').toLowerCase())
|
||||
);
|
||||
|
||||
const filtered = results.filter((r: any) => {
|
||||
const rType = (r.type || (r.is_series ? 'series' : 'movie')).toLowerCase();
|
||||
if (mediaType === 'movie') {
|
||||
return rType === 'movie' || rType === 'animes' || rType === 'anime' || rType === 'doc' || rType === 'other';
|
||||
}
|
||||
// Pour les séries
|
||||
return rType === 'series' || rType === 'serie' || rType === 'animes' || rType === 'anime' || rType === 'doc' || rType === 'other';
|
||||
});
|
||||
|
||||
return filtered.map((r: any) => ({
|
||||
title: r.name,
|
||||
year: r.year || (r.release_date ? r.release_date.substring(0, 4) : 'N/A'),
|
||||
image: r.poster || r.image || null,
|
||||
hrefPath: String(r.id),
|
||||
type: r.type || (r.is_series ? 'series' : 'movie'),
|
||||
source: 'hydracker',
|
||||
hydrackerId: String(r.id)
|
||||
}));
|
||||
}
|
||||
|
||||
export function parseTrendingResults(data: any): SearchResult[] {
|
||||
if (!data) return [];
|
||||
const results = (data.pagination || {}).data || data.data || [];
|
||||
return results.map((r: any) => ({
|
||||
title: r.name,
|
||||
year: r.year || (r.release_date ? r.release_date.substring(0, 4) : 'N/A'),
|
||||
image: r.poster || r.image || null,
|
||||
hrefPath: String(r.id),
|
||||
type: r.type || (r.is_series ? 'series' : 'movie'),
|
||||
source: 'hydracker',
|
||||
hydrackerId: String(r.id)
|
||||
})).slice(0, 19);
|
||||
}
|
||||
|
||||
export function parseMovieLinks(data: any): VideoLink[] {
|
||||
const all: any[] = [];
|
||||
if (data.video) all.push(data.video);
|
||||
if (Array.isArray(data.alternative_videos)) all.push(...data.alternative_videos);
|
||||
|
||||
return all.filter(l => l.lien).map(l => ({
|
||||
id: l.id,
|
||||
host: (l.host && l.host.name) ? l.host.name : 'Inconnu',
|
||||
url: l.lien || data.directDL,
|
||||
size: formatSize(l.taille),
|
||||
sizeBytes: l.taille || 0,
|
||||
quality: l.quality || QUALITY_MAP[l.qualite] || 'Inconnu',
|
||||
langs: getLangs(l),
|
||||
subs: getSubs(l),
|
||||
releaseName: l.release || l.name || l.titre || l.titre_release || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function parseSeasons(result: any): number[] {
|
||||
if (result && !result.error) {
|
||||
const seasons = result.seasons || (result.pagination || {}).data || [];
|
||||
if (Array.isArray(seasons) && seasons.length) {
|
||||
return seasons
|
||||
.map((s: any) => typeof s === 'object' ? (s.number || s) : s)
|
||||
.filter((n: any) => typeof n === 'number' && n > 0)
|
||||
.sort((a: number, b: number) => a - b);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function parsePremiumLink(body: string): string | null {
|
||||
let data;
|
||||
try { data = JSON.parse(body); } catch { return null; }
|
||||
|
||||
let lienData = null;
|
||||
if (data.liens && Array.isArray(data.liens) && data.liens.length > 0) {
|
||||
lienData = data.liens[0];
|
||||
} else {
|
||||
lienData = data.lien || data;
|
||||
}
|
||||
|
||||
return lienData.lien || lienData.url || lienData.link || null;
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import { ISource, SearchResult, MediaType, ContentLinks, SelectionData, VideoLink } from '../../src/types/source.js';
|
||||
import { CONFIG } from '../../src/utils/config.js';
|
||||
import { sourceRegistry } from '../../src/core/registry.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
type IndexedTitle = {
|
||||
norm: string;
|
||||
normOrig: string;
|
||||
// Distinct token list for the entry (union of norm + normOrig words).
|
||||
// Precomputed at index build time so the search hot path never
|
||||
// re-splits/dedupes these strings.
|
||||
words: string[];
|
||||
title_name: string;
|
||||
original_title: string | null;
|
||||
tmdb_id: number;
|
||||
category_name: string;
|
||||
title_poster: string | null;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export class LocalDatabaseAPI implements ISource {
|
||||
name = 'localdb';
|
||||
displayName = 'Base de données locale';
|
||||
private db: any = null;
|
||||
private dbPath: string;
|
||||
private titleIndex: IndexedTitle[] | null = null;
|
||||
// Inverted indexes used by search() to shrink the candidate set from
|
||||
// ~104K rows down to <2K before running tier scoring. Populated by
|
||||
// buildTitleIndex(); never read or written outside of that method
|
||||
// and search().
|
||||
private tokenIndex: Map<string, number[]> | null = null; // exact token -> row indices
|
||||
private titleByNorm: Map<string, number[]> | null = null; // full norm -> row indices (Tier 1)
|
||||
private prefixIndex: Map<string, number[]> | null = null; // 2-char prefix-> row indices (Tier 2 + fuzzy)
|
||||
|
||||
constructor() {
|
||||
this.dbPath = path.resolve(CONFIG.DB_PATH || './database/darkiworld.db');
|
||||
}
|
||||
|
||||
private initDb(): boolean {
|
||||
if (this.db) return true;
|
||||
if (!fs.existsSync(this.dbPath)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// readOnly avoids journal/WAL writes (plugin only reads).
|
||||
this.db = new DatabaseSync(this.dbPath, { readOnly: true });
|
||||
// Keep SQLite's temp store in RAM so big GROUP BY / sort
|
||||
// operations don't spill to /tmp (a small tmpfs in the
|
||||
// hardened container). Also bump page cache + mmap for
|
||||
// the initial index scan.
|
||||
for (const p of [
|
||||
'PRAGMA temp_store = MEMORY',
|
||||
'PRAGMA cache_size = -8000', // ~8MB page cache
|
||||
'PRAGMA mmap_size = 67108864', // 64MB mmap, not 256MB
|
||||
]) {
|
||||
this.db.prepare(p).run();
|
||||
}
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
console.error('[LocalDB] ❌ Erreur lors de l\'ouverture de la base SQLite native:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
const ok = this.initDb();
|
||||
if (ok) {
|
||||
// Warm the search indexes right after registration so the
|
||||
// first /search request doesn't eat the multi-second build
|
||||
// cost. setImmediate yields the current tick — the parallel
|
||||
// health checks of other plugins still run first.
|
||||
setImmediate(() => {
|
||||
try { this.buildTitleIndex(); }
|
||||
catch (e: any) { console.error('[LocalDB] Index warmup failed:', e.message); }
|
||||
});
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Lowercase, strip diacritics, strip apostrophes, collapse to alnum tokens.
|
||||
// "Pokémon: l'aventure" -> "pokemon l aventure"
|
||||
private static normalize(s: string | null | undefined): string {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/['"`’ʼ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Bounded Levenshtein. Returns max+1 if it would exceed `max` (cheap exit).
|
||||
private static editDistance(a: string, b: string, max: number): number {
|
||||
const la = a.length, lb = b.length;
|
||||
if (Math.abs(la - lb) > max) return max + 1;
|
||||
if (la === 0) return lb;
|
||||
if (lb === 0) return la;
|
||||
let prev = new Array(lb + 1);
|
||||
let curr = new Array(lb + 1);
|
||||
for (let j = 0; j <= lb; j++) prev[j] = j;
|
||||
for (let i = 1; i <= la; i++) {
|
||||
curr[0] = i;
|
||||
let rowMin = curr[0];
|
||||
const ai = a.charCodeAt(i - 1);
|
||||
for (let j = 1; j <= lb; j++) {
|
||||
const cost = ai === b.charCodeAt(j - 1) ? 0 : 1;
|
||||
const v = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
||||
curr[j] = v;
|
||||
if (v < rowMin) rowMin = v;
|
||||
}
|
||||
if (rowMin > max) return max + 1;
|
||||
const tmp = prev; prev = curr; curr = tmp;
|
||||
}
|
||||
return prev[lb];
|
||||
}
|
||||
|
||||
// Per-token edit-distance budget. Short words must match almost exactly;
|
||||
// longer words tolerate more typos.
|
||||
private static fuzzyBudget(tok: string): number {
|
||||
if (tok.length <= 3) return 0;
|
||||
if (tok.length <= 5) return 1;
|
||||
if (tok.length <= 8) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
private buildTitleIndex(): void {
|
||||
if (this.titleIndex !== null) return;
|
||||
if (!this.initDb()) {
|
||||
this.titleIndex = [];
|
||||
this.tokenIndex = new Map();
|
||||
this.titleByNorm = new Map();
|
||||
this.prefixIndex = new Map();
|
||||
return;
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const sql = `
|
||||
SELECT title_name,
|
||||
original_title,
|
||||
tmdb_id,
|
||||
category_name,
|
||||
title_poster,
|
||||
MIN(created_at) AS created_at
|
||||
FROM links_small
|
||||
GROUP BY title_name, tmdb_id
|
||||
`;
|
||||
const rows = this.db.prepare(sql).all() as any[];
|
||||
|
||||
const titleIndex = new Array<IndexedTitle>(rows.length);
|
||||
const tokenIndex = new Map<string, number[]>();
|
||||
const titleByNorm = new Map<string, number[]>();
|
||||
const prefixIndex = new Map<string, number[]>();
|
||||
|
||||
const push = (m: Map<string, number[]>, key: string, idx: number) => {
|
||||
const list = m.get(key);
|
||||
if (list) list.push(idx);
|
||||
else m.set(key, [idx]);
|
||||
};
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
const norm = LocalDatabaseAPI.normalize(r.title_name);
|
||||
const normOrig = LocalDatabaseAPI.normalize(r.original_title);
|
||||
|
||||
// Deduplicated union of words from both title fields.
|
||||
const seen = new Set<string>();
|
||||
const words: string[] = [];
|
||||
if (norm) for (const w of norm.split(' ')) if (w && !seen.has(w)) { seen.add(w); words.push(w); }
|
||||
if (normOrig) for (const w of normOrig.split(' ')) if (w && !seen.has(w)) { seen.add(w); words.push(w); }
|
||||
|
||||
titleIndex[i] = {
|
||||
norm, normOrig, words,
|
||||
title_name: r.title_name,
|
||||
original_title: r.original_title,
|
||||
tmdb_id: r.tmdb_id || 0,
|
||||
category_name: r.category_name,
|
||||
title_poster: r.title_poster,
|
||||
created_at: r.created_at,
|
||||
};
|
||||
|
||||
if (norm) push(titleByNorm, norm, i);
|
||||
if (normOrig && normOrig !== norm) push(titleByNorm, normOrig, i);
|
||||
for (const w of words) {
|
||||
push(tokenIndex, w, i);
|
||||
if (w.length >= 2) push(prefixIndex, w.slice(0, 2), i);
|
||||
}
|
||||
}
|
||||
|
||||
this.titleIndex = titleIndex;
|
||||
this.tokenIndex = tokenIndex;
|
||||
this.titleByNorm = titleByNorm;
|
||||
this.prefixIndex = prefixIndex;
|
||||
|
||||
console.log(`[LocalDB] Index construit: ${titleIndex.length} titres en ${Date.now() - t0}ms ` +
|
||||
`(tokens=${tokenIndex.size}, prefixes=${prefixIndex.size})`);
|
||||
}
|
||||
|
||||
private mapCategoryToType(category: string): MediaType {
|
||||
const cat = (category || '').toLowerCase().trim();
|
||||
|
||||
// Livres & BD
|
||||
if (cat.match(/\b(bd|livres?|ebooks?|magazines?|journaux)\b/)) return 'book';
|
||||
|
||||
// Jeux
|
||||
if (cat.match(/\b(jeux?|consoles?)\b/)) return 'game';
|
||||
|
||||
// Logiciels & Formations
|
||||
if (cat.match(/\b(logiciels?|formations?)\b/)) return 'software';
|
||||
|
||||
// Musique
|
||||
if (cat.match(/\b(musiques?|audio)\b/)) return 'music';
|
||||
|
||||
// Séries
|
||||
if (cat.includes('série') || cat.includes('serie') || cat.includes('tv') || cat.includes('emission')) return 'series';
|
||||
|
||||
// Animes / Dessins animés
|
||||
if (cat.includes('anime') || cat.includes('manga') || cat.includes('dessin')) return 'anime';
|
||||
|
||||
// Films (Films HD, Documentaires, Spectacles...)
|
||||
if (cat.includes('film') || cat.includes('spectacle') || cat.includes('documentaire') || cat === '') return 'movie';
|
||||
|
||||
// Tout le reste
|
||||
return 'other';
|
||||
}
|
||||
|
||||
async search(query: string, mediaType: any = 'movie'): Promise<SearchResult[]> {
|
||||
if (!this.initDb()) {
|
||||
console.warn('[LocalDB] ⚠️ Base de données non initialisée ou introuvable.');
|
||||
return [];
|
||||
}
|
||||
this.buildTitleIndex();
|
||||
if (!this.titleIndex || this.titleIndex.length === 0) return [];
|
||||
|
||||
const t0 = Date.now();
|
||||
const q = LocalDatabaseAPI.normalize(query);
|
||||
if (!q) return [];
|
||||
const tokens = q.split(' ').filter(Boolean);
|
||||
if (tokens.length === 0) return [];
|
||||
|
||||
// Candidate row indices, gathered from the inverted indexes. For
|
||||
// a typical query this drops the working set from ~104K rows to
|
||||
// a few hundred. Rows that don't show up here cannot match Tier
|
||||
// 1, 2, 3 or 5 — the only thing they could theoretically hit is
|
||||
// Tier 4 substring-inside-a-word, which is rare enough not to
|
||||
// justify a trigram index.
|
||||
const candidates = new Set<number>();
|
||||
const exactHits = this.titleByNorm!.get(q);
|
||||
if (exactHits) for (const i of exactHits) candidates.add(i);
|
||||
for (const tok of tokens) {
|
||||
const rows = this.tokenIndex!.get(tok);
|
||||
if (rows) for (const i of rows) candidates.add(i);
|
||||
if (tok.length >= 2) {
|
||||
const pRows = this.prefixIndex!.get(tok.slice(0, 2));
|
||||
if (pRows) for (const i of pRows) candidates.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
const scored: Array<{ idx: number; score: number }> = [];
|
||||
|
||||
for (const i of candidates) {
|
||||
const entry = this.titleIndex[i];
|
||||
const t = entry.norm;
|
||||
const o = entry.normOrig;
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Tier 1: exact normalized match on either title field
|
||||
if (t === q || (o && o === q)) {
|
||||
score = 1000;
|
||||
}
|
||||
// Tier 2: title starts with the full query
|
||||
else if (t.startsWith(q) || (o && o.startsWith(q))) {
|
||||
score = 800;
|
||||
}
|
||||
// Tier 3: query appears as a whole-word substring
|
||||
else if ((' ' + t + ' ').includes(' ' + q + ' ') ||
|
||||
(o && (' ' + o + ' ').includes(' ' + q + ' '))) {
|
||||
score = 700;
|
||||
}
|
||||
// Tier 4: raw substring (partial word)
|
||||
else if (t.includes(q) || (o && o.includes(q))) {
|
||||
score = 600;
|
||||
}
|
||||
// Tier 5: per-token matching, exact-then-fuzzy, any word order.
|
||||
// Uses the precomputed entry.words instead of re-splitting on
|
||||
// every row.
|
||||
else {
|
||||
const words = entry.words;
|
||||
let exactMatched = 0;
|
||||
let fuzzyMatched = 0;
|
||||
let fuzzyPenalty = 0;
|
||||
let anyMatched = false;
|
||||
|
||||
for (const tok of tokens) {
|
||||
let exact = false;
|
||||
for (const w of words) {
|
||||
if (w === tok || w.startsWith(tok)) { exact = true; break; }
|
||||
}
|
||||
if (exact) {
|
||||
exactMatched++;
|
||||
anyMatched = true;
|
||||
continue;
|
||||
}
|
||||
const budget = LocalDatabaseAPI.fuzzyBudget(tok);
|
||||
if (budget === 0) continue;
|
||||
let best = budget + 1;
|
||||
for (const w of words) {
|
||||
if (Math.abs(w.length - tok.length) > budget) continue;
|
||||
const d = LocalDatabaseAPI.editDistance(tok, w, budget);
|
||||
if (d < best) { best = d; if (best <= 1) break; }
|
||||
}
|
||||
if (best <= budget) {
|
||||
fuzzyMatched++;
|
||||
fuzzyPenalty += best;
|
||||
anyMatched = true;
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatched = exactMatched + fuzzyMatched;
|
||||
if (totalMatched === tokens.length) {
|
||||
// All tokens covered — strong signal even when some were fuzzy
|
||||
score = 400 - fuzzyPenalty * 30 + exactMatched * 5;
|
||||
} else if (anyMatched) {
|
||||
// Partial coverage — only meaningful for multi-word queries
|
||||
score = Math.round(120 * (totalMatched / tokens.length)) - fuzzyPenalty * 10;
|
||||
}
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
// Tiebreakers: shorter titles win; original_title field is a small bonus when it helped
|
||||
score += Math.max(0, 30 - t.length);
|
||||
scored.push({ idx: i, score });
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
const results: SearchResult[] = scored.slice(0, 150).map(({ idx }) => {
|
||||
const r = this.titleIndex![idx];
|
||||
const type = this.mapCategoryToType(r.category_name);
|
||||
return {
|
||||
title: r.title_name,
|
||||
year: r.created_at ? r.created_at.substring(0, 4) : null,
|
||||
image: r.title_poster || null,
|
||||
hrefPath: `localdb:${r.tmdb_id}:${r.title_name}`,
|
||||
type,
|
||||
source: this.name
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = (mediaType === 'movie')
|
||||
? results.filter(r => r.type === 'movie' || r.type === 'anime')
|
||||
: (mediaType === 'series')
|
||||
? results.filter(r => r.type === 'series' || r.type === 'anime')
|
||||
: (mediaType === 'movie_series')
|
||||
? results.filter(r => r.type === 'movie' || r.type === 'series' || r.type === 'anime')
|
||||
: results.filter(r => r.type === mediaType);
|
||||
|
||||
console.log(`[LocalDB] search "${query}" → ${candidates.size} candidats, ${filtered.length} résultats en ${Date.now() - t0}ms`);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||
// Pas de tendances en base de données locale
|
||||
return [];
|
||||
}
|
||||
|
||||
// Distinct quality/host values from the DB, grouped by media bucket.
|
||||
// Cached after the first call — the underlying data is static.
|
||||
private optionsCache: { qualities: { movies: string[]; series: string[] }; hosts: string[] } | null = null;
|
||||
listConfigOptions(): { qualities: { movies: string[]; series: string[] }; hosts: string[] } {
|
||||
if (this.optionsCache) return this.optionsCache;
|
||||
const empty = { qualities: { movies: [] as string[], series: [] as string[] }, hosts: [] as string[] };
|
||||
if (!this.initDb()) return empty;
|
||||
try {
|
||||
const movieCats = ['Films', 'Animes', 'Films et series', 'Documentaire', 'Spectacle'];
|
||||
const seriesCats = ['Séries', 'Animes', 'Téléréalité', 'Émissions TV', 'Mangas'];
|
||||
const sql = (cats: string[]) => `
|
||||
SELECT DISTINCT quality_name FROM links_small
|
||||
WHERE category_name IN (${cats.map(() => '?').join(',')})
|
||||
AND quality_name IS NOT NULL AND quality_name != ''
|
||||
ORDER BY quality_name`;
|
||||
const pick = (cats: string[]): string[] =>
|
||||
this.db.prepare(sql(cats)).all(...cats).map((r: any) => r.quality_name);
|
||||
|
||||
const hosts = this.db.prepare(
|
||||
`SELECT DISTINCT host_name FROM links_small
|
||||
WHERE host_name IS NOT NULL AND host_name != ''
|
||||
ORDER BY host_name`
|
||||
).all().map((r: any) => r.host_name);
|
||||
|
||||
this.optionsCache = {
|
||||
qualities: { movies: pick(movieCats), series: pick(seriesCats) },
|
||||
hosts,
|
||||
};
|
||||
return this.optionsCache;
|
||||
} catch (e: any) {
|
||||
console.error('[LocalDB] listConfigOptions error:', e.message);
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string): { tmdbId: number; titleName: string } {
|
||||
const parts = identifier.split(':');
|
||||
if (parts[0] === 'localdb') {
|
||||
return {
|
||||
tmdbId: parseInt(parts[1], 10) || 0,
|
||||
titleName: parts.slice(2).join(':')
|
||||
};
|
||||
}
|
||||
return { tmdbId: 0, titleName: identifier };
|
||||
}
|
||||
|
||||
async getContentLinks(identifier: string, season: number = 1): Promise<ContentLinks> {
|
||||
if (!this.initDb()) return { links: [] };
|
||||
|
||||
const { tmdbId, titleName } = this.parseIdentifier(identifier);
|
||||
|
||||
try {
|
||||
let categoryStmt = this.db.prepare('SELECT category_name FROM links_small WHERE tmdb_id = ? OR title_name = ? LIMIT 1');
|
||||
let sample = categoryStmt.get(tmdbId, titleName) as any;
|
||||
|
||||
if (!sample && tmdbId > 0) {
|
||||
sample = categoryStmt.get(0, titleName) as any;
|
||||
}
|
||||
|
||||
if (!sample) return { links: [] };
|
||||
|
||||
const mediaType = this.mapCategoryToType(sample.category_name);
|
||||
if (!mediaType) return { links: [] };
|
||||
|
||||
const isSeries = mediaType === 'series';
|
||||
let rows: any[] = [];
|
||||
|
||||
if (isSeries) {
|
||||
const sql = `
|
||||
SELECT * FROM links_small
|
||||
WHERE (tmdb_id = ? OR title_name = ?) AND season_number = ?
|
||||
ORDER BY episode_number ASC, quality_name DESC
|
||||
`;
|
||||
rows = this.db.prepare(sql).all(tmdbId, titleName, season) as any[];
|
||||
} else {
|
||||
const sql = `
|
||||
SELECT * FROM links_small
|
||||
WHERE tmdb_id = ? OR title_name = ?
|
||||
ORDER BY quality_name DESC
|
||||
`;
|
||||
rows = this.db.prepare(sql).all(tmdbId, titleName) as any[];
|
||||
}
|
||||
|
||||
const splitLangs = (s: string | null | undefined): string[] => {
|
||||
if (!s) return [];
|
||||
return s.split(/[,;/]+/).map(p => p.trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
const links: VideoLink[] = rows.map((row: any, i: number) => {
|
||||
const idKey = row.link_id != null ? String(row.link_id) : `local_${i}`;
|
||||
const audioLangs = splitLangs(row.audio_langs);
|
||||
const subLangs = splitLangs(row.sub_langs);
|
||||
|
||||
// Legacy `langs` field — kept for plugins/clients that don't
|
||||
// know about audioLangs/subLangs yet.
|
||||
const langsList = [...audioLangs];
|
||||
if (subLangs.length) langsList.push(`Subs: ${subLangs.join(', ')}`);
|
||||
|
||||
return {
|
||||
id: idKey,
|
||||
host: row.host_name || 'Inconnu',
|
||||
url: row.link_url || null,
|
||||
size: row.size_human || '0 Bytes',
|
||||
sizeBytes: row.size_bytes || 0,
|
||||
quality: row.quality_name || 'BDRip',
|
||||
langs: langsList,
|
||||
episode: row.is_full_season
|
||||
? 'Saison complète'
|
||||
: (row.episode_number ? `Épisode ${row.episode_number}` : null),
|
||||
episodeNumber: row.episode_number || null,
|
||||
episodeName: row.episode_name || null,
|
||||
isFullSeason: !!row.is_full_season,
|
||||
audioLangs,
|
||||
subLangs,
|
||||
};
|
||||
});
|
||||
|
||||
return { links };
|
||||
} catch (e: any) {
|
||||
console.error('[LocalDB] Erreur getContentLinks:', e.message);
|
||||
return { links: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||
if (!this.initDb()) return { links: [], seasons: [], isSeries: false };
|
||||
|
||||
const { tmdbId, titleName } = this.parseIdentifier(identifier);
|
||||
|
||||
try {
|
||||
const sample = this.db.prepare('SELECT category_name FROM links_small WHERE tmdb_id = ? OR title_name = ? LIMIT 1').get(tmdbId, titleName) as any;
|
||||
if (!sample) return { links: [], seasons: [], isSeries: false };
|
||||
|
||||
const mediaType = this.mapCategoryToType(sample.category_name);
|
||||
if (!mediaType) return { links: [], seasons: [], isSeries: false };
|
||||
|
||||
const isSeries = mediaType === 'series';
|
||||
let seasonsList: any[] = [];
|
||||
let currentSeason = 1;
|
||||
|
||||
if (isSeries) {
|
||||
const seasonsRows = this.db.prepare(`
|
||||
SELECT DISTINCT season_number
|
||||
FROM links_small
|
||||
WHERE tmdb_id = ? OR title_name = ?
|
||||
ORDER BY season_number ASC
|
||||
`).all(tmdbId, titleName) as any[];
|
||||
|
||||
seasonsList = seasonsRows.map((r: any) => ({
|
||||
label: `Saison ${r.season_number}`,
|
||||
value: r.season_number
|
||||
}));
|
||||
|
||||
if (seasonValue) {
|
||||
currentSeason = parseInt(String(seasonValue), 10) || 1;
|
||||
} else if (seasonsRows.length > 0) {
|
||||
// Prefer season 1 if it exists (matches the UI's auto-selected
|
||||
// dropdown option); otherwise fall back to the lowest season
|
||||
// number — usually "Saison 0" specials.
|
||||
const hasSeason1 = seasonsRows.some((r: any) => r.season_number === 1);
|
||||
currentSeason = hasSeason1 ? 1 : seasonsRows[0].season_number;
|
||||
}
|
||||
}
|
||||
|
||||
const content = await this.getContentLinks(identifier, currentSeason);
|
||||
|
||||
return {
|
||||
links: content.links,
|
||||
seasons: seasonsList,
|
||||
isSeries
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.error('[LocalDB] Erreur getSelection:', e.message);
|
||||
return { links: [], seasons: [], isSeries: false };
|
||||
}
|
||||
}
|
||||
|
||||
resolveLocalLink(linkId: string | number): string | null {
|
||||
if (!this.initDb()) return null;
|
||||
try {
|
||||
const row = this.db.prepare('SELECT link_url FROM links_small WHERE link_id = ? LIMIT 1').get(linkId) as any;
|
||||
if (row && row.link_url) {
|
||||
return row.link_url;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[LocalDB] Erreur resolveLocalLink:', e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Enregistrement automatique du plugin
|
||||
sourceRegistry.register(new LocalDatabaseAPI());
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Appels réseau pour zt.news.
|
||||
* Pas de challenge CF actif, fetch direct simple.
|
||||
*/
|
||||
|
||||
const TIMEOUT = 20_000;
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0';
|
||||
|
||||
async function ztnGet(url: string): Promise<string> {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export async function fetchSearch(baseUrl: string, query: string): Promise<string> {
|
||||
return ztnGet(`${baseUrl}/?p=films&search=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* News n'a pas vraiment de page "nouveautés" séparée — la home expose déjà
|
||||
* une grille de blocs cover_global avec les derniers films/séries.
|
||||
*/
|
||||
export async function fetchTrending(baseUrl: string, type: 'films' | 'series'): Promise<string> {
|
||||
return ztnGet(`${baseUrl}/?p=${type}`);
|
||||
}
|
||||
|
||||
export async function fetchPage(pageUrl: string): Promise<string> {
|
||||
return ztnGet(pageUrl);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { ISource, SearchResult, MediaType, SelectionData, ContentLinks } from '../../src/types/source.js';
|
||||
import { CONFIG } from '../../src/utils/config.js';
|
||||
import { sourceRegistry } from '../../src/core/registry.js';
|
||||
import { fetchSearch, fetchTrending, fetchPage } from './api.js';
|
||||
import { parseListingHTML, parseContentHTML, parseOtherVersions } from './parser.js';
|
||||
|
||||
function isSeriesIdentifier(identifier: string): boolean {
|
||||
return /[?&]p=serie\b|telecharger-serie/i.test(identifier);
|
||||
}
|
||||
|
||||
function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/-\s*saison\s*\d+/gi, '')
|
||||
.replace(/\(\s*\d{4}\s*\)/g, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function deduplicateByTitle(results: SearchResult[]): SearchResult[] {
|
||||
const seen = new Set<string>();
|
||||
return results.filter(r => {
|
||||
const key = normalizeTitle(r.title);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export class ZtTeamAPI implements ISource {
|
||||
name = 'ztteam';
|
||||
displayName = 'ZT (Team)';
|
||||
private baseUrl: string | undefined;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl?.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!this.baseUrl) {
|
||||
console.warn('[ztnews] ⚠️ ZTTEAM_URL non définie.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) throw new Error('ZTTEAM_URL non configurée.');
|
||||
if (!query || query.length < 3) throw new Error('La recherche nécessite au moins 3 caractères.');
|
||||
const html = await fetchSearch(this.baseUrl, query);
|
||||
let results = parseListingHTML(html, this.baseUrl);
|
||||
if (mediaType === 'movie') {
|
||||
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
|
||||
} else {
|
||||
results = results.filter(r => r.type === 'series' || r.type === 'anime');
|
||||
}
|
||||
return deduplicateByTitle(results);
|
||||
}
|
||||
|
||||
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) return [];
|
||||
try {
|
||||
const html = await fetchTrending(this.baseUrl, mediaType === 'series' ? 'series' : 'films');
|
||||
const results = parseListingHTML(html, this.baseUrl);
|
||||
return deduplicateByTitle(results).slice(0, 20);
|
||||
} catch (e: any) {
|
||||
console.error(`[ztnews] Erreur trending ${mediaType}:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getRecent(): Promise<SearchResult[]> {
|
||||
if (!this.baseUrl) return [];
|
||||
try {
|
||||
const html = await fetchPage(this.baseUrl);
|
||||
const results = parseListingHTML(html, this.baseUrl);
|
||||
return deduplicateByTitle(results).slice(0, 20);
|
||||
} catch (e: any) {
|
||||
console.error(`[ztnews] Erreur getRecent:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getContentLinks(identifier: string): Promise<ContentLinks> {
|
||||
if (!this.baseUrl) throw new Error('ZTTEAM_URL non configurée.');
|
||||
const url = identifier.startsWith('http') ? identifier : `${this.baseUrl}/${identifier.replace(/^\//, '')}`;
|
||||
const html = await fetchPage(url);
|
||||
return parseContentHTML(html, isSeriesIdentifier(identifier));
|
||||
}
|
||||
|
||||
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||
if (!this.baseUrl) throw new Error('ZTTEAM_URL non configurée.');
|
||||
const targetIdentifier = seasonValue ? String(seasonValue) : identifier;
|
||||
const isSeries = isSeriesIdentifier(targetIdentifier);
|
||||
const url = targetIdentifier.startsWith('http') ? targetIdentifier : `${this.baseUrl}/${targetIdentifier.replace(/^\//, '')}`;
|
||||
const html = await fetchPage(url);
|
||||
const content = parseContentHTML(html, isSeries);
|
||||
|
||||
const allLinks = [...content.links];
|
||||
let seasons: { label: string; value: string }[] = [];
|
||||
|
||||
if (!isSeries) {
|
||||
const otherVersions = parseOtherVersions(html, this.baseUrl);
|
||||
if (otherVersions.length > 0) {
|
||||
console.log(`[ztnews] Fetching ${otherVersions.length} other qualities concurrently...`);
|
||||
const qualityPromises = otherVersions.map(async (q) => {
|
||||
try {
|
||||
const qHtml = await fetchPage(q.value);
|
||||
const qContent = parseContentHTML(qHtml, isSeries);
|
||||
return qContent.links;
|
||||
} catch (e) {
|
||||
console.error(`[ztnews] Error fetching quality page ${q.value}:`, e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const otherQualitiesLinks = await Promise.all(qualityPromises);
|
||||
otherQualitiesLinks.forEach(links => allLinks.push(...links));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
links: allLinks,
|
||||
seasons,
|
||||
isSeries,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveLink(linkId: string): Promise<string | null> {
|
||||
console.log(`[ztTeam] 🔗 Renvoi du lien dl-protect brut (résolution via navigateur ou JDownloader requise) : ${linkId}`);
|
||||
return linkId || null;
|
||||
}
|
||||
}
|
||||
|
||||
sourceRegistry.register(new ZtTeamAPI(CONFIG.ZTTEAM_URL));
|
||||
@@ -0,0 +1,169 @@
|
||||
import { SearchResult, ContentLinks, VideoLink } from '../../src/types/source.js';
|
||||
|
||||
|
||||
function decodeFnMeta(url: string): { quality?: string; langs?: string[] } {
|
||||
try {
|
||||
const m = url.match(/[?&]fn=([^&]+)/);
|
||||
if (!m) return {};
|
||||
const decoded = Buffer.from(decodeURIComponent(m[1]!), 'base64').toString('utf-8');
|
||||
const qm = decoded.match(/\[([^\]]+)\]/);
|
||||
const quality = qm ? qm[1]!.trim() : undefined;
|
||||
// Tout après " - " jusqu'à la fin (typiquement la langue : FRENCH, MULTI, VOSTFR…)
|
||||
const lm = decoded.match(/-\s+([A-Za-z]+(?:\s+[A-Za-z]+)?)$/);
|
||||
const langs = lm ? [lm[1]!.trim()] : undefined;
|
||||
return { quality, langs };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function detectType(href: string): 'movie' | 'series' | 'anime' {
|
||||
if (/[?&]p=serie\b|telecharger-serie|serie-/i.test(href)) return 'series';
|
||||
if (/animes?/i.test(href)) return 'anime';
|
||||
return 'movie';
|
||||
}
|
||||
|
||||
function absUrl(url: string, baseUrl: string): string {
|
||||
if (url.startsWith('http')) return url;
|
||||
const cleanedBase = baseUrl.replace(/\/$/, '');
|
||||
return cleanedBase + (url.startsWith('/') ? url : '/' + url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip suffixes de qualité/langue pour dedup par titre normalisé.
|
||||
*/
|
||||
function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/\b(web-?dl|web-?rip|blu-?ray|hdtv|hdrip|dvdrip|hdlight|truefrench|french|multi(?:langues?)?|vff|vf|vostfr|x264|x265|hevc)\b/g, '')
|
||||
.replace(/\b(720p|1080p|2160p|4k|uhd|3d|sd|hd)\b/g, '')
|
||||
.replace(/\(\s*\d{4}\s*\)/g, '')
|
||||
.replace(/-\s*saison\s*\d+/gi, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function deduplicateByTitle<T extends { title: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter(it => {
|
||||
const k = normalizeTitle(it.title);
|
||||
if (!k || seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* News utilise la structure DLE classique avec cover_global / cover_infos_title / mainimg
|
||||
* sur la home/listing/recherche. On peut donc partager le parser de listing.
|
||||
*/
|
||||
export function parseListingHTML(html: string, baseUrl: string): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
const coverRegex = /<div class="cover_global"[^>]*>([\s\S]*?)(?=<div class="cover_global"|$)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = coverRegex.exec(html)) !== null) {
|
||||
const block = m[1]!;
|
||||
const titleMatch = block.match(/<div class="cover_infos_title"[^>]*>\s*<a href="([^"]+)"[^>]*>\s*([^<]+)/);
|
||||
if (!titleMatch) continue;
|
||||
const href = absUrl(titleMatch[1]!.trim(), baseUrl);
|
||||
const title = titleMatch[2]!.trim();
|
||||
const imgMatch = block.match(/<img class="mainimg"[^>]*src="([^"]+)"/);
|
||||
const image = imgMatch ? absUrl(imgMatch[1]!, baseUrl) : null;
|
||||
results.push({
|
||||
title,
|
||||
year: null,
|
||||
image,
|
||||
hrefPath: href,
|
||||
type: detectType(titleMatch[1]!),
|
||||
source: 'ztnews',
|
||||
});
|
||||
}
|
||||
return deduplicateByTitle(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse la fiche film/série de news.
|
||||
* Structure dans <div class="postinfo">:
|
||||
* <div style="color:#XXX">HOST_NAME</div>
|
||||
* <a href="dl-protect.link/SLUG?fn=...&rl=a2">Télécharger</a> (film)
|
||||
* <a href="dl-protect.link/SLUG?fn=...&rl=b2">Episode N</a> (série, plusieurs liens)
|
||||
*/
|
||||
export function parseContentHTML(html: string, isSeries: boolean): ContentLinks {
|
||||
const links: VideoLink[] = [];
|
||||
|
||||
const postMatch = html.match(/<div class="postinfo">([\s\S]*?)<\/div>\s*<\/center>/);
|
||||
if (!postMatch) return { links };
|
||||
const post = postMatch[1]!;
|
||||
|
||||
// Découper par hôte : chaque hôte est marqué par <div style="font-weight:bold;color:#XXX">HOST</div>
|
||||
const hostSplit = post.split(/<div\s+style="font-weight:bold;color:#[0-9a-fA-F]+">([^<]+)<\/div>/);
|
||||
// hostSplit[0] = pre-section, puis alterne (HOST, BLOCK)
|
||||
for (let i = 1; i < hostSplit.length; i += 2) {
|
||||
const host = hostSplit[i]!.trim();
|
||||
const block = hostSplit[i + 1] || '';
|
||||
// Tous les <a href="dl-protect.link..."> dans cette section
|
||||
const linkRegex = /<a[^>]+href="(https?:\/\/dl-protect\.link\/[0-9a-fA-F]+\?[^"]*?rl=[ab]2[^"]*)"[^>]*>([^<]+)<\/a>/g;
|
||||
let lm: RegExpExecArray | null;
|
||||
while ((lm = linkRegex.exec(block)) !== null) {
|
||||
const url = lm[1]!;
|
||||
const label = lm[2]!.trim();
|
||||
const epMatch = label.match(/Episode\s*(\d+|FiNAL|Final|final)/i);
|
||||
const meta = decodeFnMeta(url);
|
||||
|
||||
let quality = meta.quality || 'Inconnu';
|
||||
let langs: string[] = [];
|
||||
let subs: string[] = [];
|
||||
|
||||
const textToScan = `${quality} ${label}`;
|
||||
const langMatch = textToScan.match(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi);
|
||||
if (langMatch) {
|
||||
const seenLangs = new Set<string>();
|
||||
const seenSubs = new Set<string>();
|
||||
langMatch.forEach(l => {
|
||||
const up = l.toUpperCase();
|
||||
if (up.includes('VOSTFR')) { seenLangs.add('VOSTFR'); seenSubs.add('French'); }
|
||||
else if (up.includes('TRUEFRENCH')) seenLangs.add('TrueFrench');
|
||||
else if (up.includes('FRENCH') || up === 'VF' || up === 'VFF') seenLangs.add('French');
|
||||
else if (up.includes('MULTI')) { seenLangs.add('MULTI'); seenSubs.add('Multi'); }
|
||||
});
|
||||
langs = Array.from(seenLangs);
|
||||
subs = Array.from(seenSubs);
|
||||
|
||||
quality = quality.replace(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi, '').trim();
|
||||
}
|
||||
|
||||
quality = quality.replace(/[\(\)\[\]\-]+$/g, '').replace(/[\(\)\[\]]/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (!quality || quality.toLowerCase() === 'inconnu') quality = 'WEB';
|
||||
|
||||
links.push({
|
||||
id: url,
|
||||
host: host.toLowerCase(),
|
||||
label: isSeries ? `${label} — ${host}` : host,
|
||||
episode: epMatch ? epMatch[1] : undefined,
|
||||
quality: quality,
|
||||
langs: langs,
|
||||
subs: subs,
|
||||
url: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { links };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les autres versions/qualités du film depuis la section "Qualités également disponibles".
|
||||
*/
|
||||
export function parseOtherVersions(html: string, baseUrl: string): { label: string; value: string }[] {
|
||||
const out: { label: string; value: string }[] = [];
|
||||
const sectionMatch = html.match(/<div class="otherversions"[\s\S]*?<\/div>/);
|
||||
if (!sectionMatch) return out;
|
||||
const linkRegex = /<a\s+href="([^"]+)"[^>]*>\s*<span class="otherquality">([\s\S]*?)<\/span>\s*<\/a>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = linkRegex.exec(sectionMatch[0])) !== null) {
|
||||
const href = absUrl(m[1]!, baseUrl);
|
||||
const label = m[2]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (label && !out.find(o => o.value === href)) out.push({ label, value: href });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Helper functions for password toggles and validation shared between login, setup, and overlay
|
||||
window.AuthHelpers = {
|
||||
/**
|
||||
* Initializes password visibility toggles.
|
||||
* @param {HTMLElement} rootEl
|
||||
*/
|
||||
initPasswordToggles(rootEl = document) {
|
||||
rootEl.querySelectorAll('.toggle-password').forEach(btn => {
|
||||
if (btn.dataset.initialized) return;
|
||||
btn.dataset.initialized = 'true';
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
const input = btn.parentElement.querySelector('input');
|
||||
if (!input) return;
|
||||
|
||||
const isPassword = input.type === 'password';
|
||||
input.type = isPassword ? 'text' : 'password';
|
||||
|
||||
btn.innerHTML = isPassword
|
||||
? '<i data-lucide="eye-off" class="eye-icon"></i>'
|
||||
: '<i data-lucide="eye" class="eye-icon"></i>';
|
||||
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons({ root: btn });
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Performs client-side password validation against complexity rules.
|
||||
* @param {string} val
|
||||
* @param {string} confirmVal
|
||||
* @returns {Object}
|
||||
*/
|
||||
validateComplexity(val, confirmVal) {
|
||||
const hasLength = val.length >= 8;
|
||||
const hasUpper = /[A-Z]/.test(val);
|
||||
const hasNumber = /[0-9]/.test(val);
|
||||
const hasSpecial = /[^a-zA-Z0-9]/.test(val);
|
||||
const matches = val === confirmVal && val.length > 0;
|
||||
|
||||
return {
|
||||
hasLength,
|
||||
hasUpper,
|
||||
hasNumber,
|
||||
hasSpecial,
|
||||
matches,
|
||||
allValid: hasLength && hasUpper && hasNumber && hasSpecial && matches
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates requirements list checklist UI.
|
||||
* @param {HTMLElement} rootEl
|
||||
* @param {Object} statuses
|
||||
*/
|
||||
updateRequirementsUI(rootEl, statuses) {
|
||||
const updateReq = (id, isValid) => {
|
||||
const li = rootEl.querySelector(`#${id}`);
|
||||
if (!li) return;
|
||||
li.className = isValid ? 'valid' : 'invalid';
|
||||
|
||||
const holder = li.querySelector('.icon-holder');
|
||||
if (holder) {
|
||||
holder.innerHTML = isValid
|
||||
? '<i data-lucide="check" style="width:14px;height:14px;"></i>'
|
||||
: '<i data-lucide="x" style="width:14px;height:14px;"></i>';
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons({ root: holder });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateReq('req-length', statuses.hasLength);
|
||||
updateReq('req-upper', statuses.hasUpper);
|
||||
updateReq('req-number', statuses.hasNumber);
|
||||
updateReq('req-special', statuses.hasSpecial);
|
||||
updateReq('req-match', statuses.matches);
|
||||
}
|
||||
};
|
||||
+1733
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 620 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 620 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Vendored
+12
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Hydr'Hacked",
|
||||
"short_name": "Hydr'Hacked",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#1a1a1a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "images/icone-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "images/icone-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
+1173
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
const CACHE_NAME = 'hydrhacked-v4.8';
|
||||
const STATIC_ASSETS = [
|
||||
'/style.css',
|
||||
'/app.auth.js',
|
||||
'/app.js',
|
||||
'/manifest.json',
|
||||
'/images/icone-192.png',
|
||||
'/images/icone-512.png',
|
||||
'/images/logo_transparent.png',
|
||||
'/lucide.min.js'
|
||||
];
|
||||
|
||||
// 1. INSTALLATION
|
||||
self.addEventListener('install', event => {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => {
|
||||
console.log('[SW] Mise en cache des fichiers statiques');
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cache => {
|
||||
if (cache !== CACHE_NAME) {
|
||||
console.log('[SW] Suppression ancien cache:', cache);
|
||||
return caches.delete(cache);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
return self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
const isStaticAsset = url.pathname.endsWith('.css') ||
|
||||
url.pathname.endsWith('.js') ||
|
||||
url.pathname.endsWith('.png') ||
|
||||
url.pathname.endsWith('.json');
|
||||
|
||||
if (!isStaticAsset || event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stratégie Network-First : on tente le réseau, et on met à jour le cache. Sinon, fallback sur le cache.
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then(response => {
|
||||
if (response && response.status === 200) {
|
||||
const responseCopy = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => {
|
||||
cache.put(event.request, responseCopy);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import { sourceRegistry } from './registry.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/**
|
||||
* Scanne le dossier plugins.
|
||||
* Chaque fichier s'auto-enregistre dans le registry au chargement du module.
|
||||
* Ensuite, les health checks sont lancés pour ne garder que les sources fonctionnelles.
|
||||
*/
|
||||
export async function discoverSources(): Promise<void> {
|
||||
const pluginsDir = path.join(__dirname, '../../plugins');
|
||||
console.log(`[Discovery] Scan du dossier plugins/... (${pluginsDir})`);
|
||||
|
||||
if (!fs.existsSync(pluginsDir)) {
|
||||
console.warn('[Discovery] Aucun dossier plugins trouvé.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginFolders = fs.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name);
|
||||
|
||||
if (!pluginFolders.length) {
|
||||
console.warn('[Discovery] Aucun plugin trouvé.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const folder of pluginFolders) {
|
||||
// En ES modules TypeScript compile, ce sera index.js (ou index.ts si on utilise ts-node)
|
||||
// On essaie d'importer le dossier directement, Node (avec moduleResolution: NodeNext)
|
||||
// ou la configuration devrait trouver le index.js s'il est là.
|
||||
// Plus sûr : importer explicitement le fichier index.js
|
||||
const indexPath = pathToFileURL(path.join(pluginsDir, folder, 'index.js')).href;
|
||||
try {
|
||||
await import(indexPath);
|
||||
console.log(`[Discovery] 📦 Plugin ${folder} chargé`);
|
||||
} catch (err: any) {
|
||||
console.error(`[Discovery] ⚠️ Erreur chargement plugin ${folder}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Lance les health checks et ne garde que les sources fonctionnelles
|
||||
await sourceRegistry.initialize();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ISource } from '../types/source.js';
|
||||
|
||||
/**
|
||||
* Les fichiers sources s'auto-enregistrent via `register()` au chargement du module
|
||||
* `initialize()` lance les health checks et ne garde que les sources fonctionnelles
|
||||
* Les routes et le state n'interagissent qu'avec les sources actives
|
||||
*/
|
||||
class SourceRegistry {
|
||||
private pending: ISource[] = [];
|
||||
private active = new Map<string, ISource>();
|
||||
private allRegistered = new Map<string, ISource>();
|
||||
|
||||
/**
|
||||
* Enregistre une source dans la file d'attente.
|
||||
* Appelé automatiquement par chaque fichier source au chargement.
|
||||
*/
|
||||
register(source: ISource) {
|
||||
this.pending.push(source);
|
||||
this.allRegistered.set(source.name, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne toutes les sources enregistrées (actives ou inactives).
|
||||
*/
|
||||
getAllRegistered(): ISource[] {
|
||||
return Array.from(this.allRegistered.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance les health checks sur toutes les sources en attente (en parallèle)
|
||||
* Seules les sources qui passent le check sont dites"active"
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
console.log(`[Registry] ${this.pending.length} source(s) détectée(s), lancement des health checks...`);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
this.pending.map(async (source) => {
|
||||
const healthy = await source.healthCheck();
|
||||
return { source, healthy };
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
const { source, healthy } = result.value;
|
||||
if (healthy) {
|
||||
this.active.set(source.name, source);
|
||||
console.log(`[Registry] ✅ ${source.name.toUpperCase()} — opérationnelle`);
|
||||
|
||||
} else {
|
||||
console.warn(`[Registry] ❌ ${source.name.toUpperCase()} — non disponible`);
|
||||
}
|
||||
} else {
|
||||
console.error(`[Registry] ❌ Health check crash:`, result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
this.pending = [];
|
||||
const names = this.getAvailableNames();
|
||||
console.log(`[Registry] ${this.active.size} source(s) active(s): ${names.length ? names.map(n => n.toUpperCase()).join(', ') : 'Aucune'}`);
|
||||
}
|
||||
|
||||
get(name: string): ISource | null {
|
||||
return this.active.get(name) || null;
|
||||
}
|
||||
|
||||
getAll(): ISource[] {
|
||||
return Array.from(this.active.values());
|
||||
}
|
||||
|
||||
getAvailableNames(): string[] {
|
||||
return Array.from(this.active.keys());
|
||||
}
|
||||
|
||||
getDefault(): ISource | null {
|
||||
const first = this.active.values().next();
|
||||
return first.done ? null : first.value;
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.active.has(name);
|
||||
}
|
||||
}
|
||||
|
||||
export const sourceRegistry = new SourceRegistry();
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import connectSessionFileStore from 'session-file-store';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const FileStore = connectSessionFileStore(session);
|
||||
|
||||
import { CONFIG } from './utils/config.js';
|
||||
import { globalState, checkSiteStatus } from './utils/state.js';
|
||||
import { discoverSources } from './core/discovery.js';
|
||||
import { sourceRegistry } from './core/registry.js';
|
||||
import { hasAnyUser, createUser } from './utils/userStore.js';
|
||||
|
||||
import authRoutes from './routes/auth.js';
|
||||
import apiRoutes from './routes/api.js';
|
||||
import jdRoutes from './routes/jd.js';
|
||||
import proxyRoutes from './routes/proxy.js';
|
||||
import viewRoutes from './routes/views.js';
|
||||
import setupRoutes from './routes/setup.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = CONFIG.PORT;
|
||||
|
||||
// Configuration EJS
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(process.cwd(), 'views'));
|
||||
|
||||
// ========================= MIDDLEWARES SÉCURITÉ =========================
|
||||
|
||||
// CORS : désactivé (app self-hosted, pas besoin de cross-origin)
|
||||
// Remplace l'ancien cors() ouvert qui permettait tout origin
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
connectSrc: ["'self'"],
|
||||
"upgrade-insecure-requests": null,
|
||||
}
|
||||
},
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" }
|
||||
}));
|
||||
|
||||
// Body parser avec limite de taille — anti DoS mémoire
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: false, limit: '1mb' }));
|
||||
|
||||
app.set('trust proxy', 1);
|
||||
app.use(cookieParser());
|
||||
|
||||
// Session avec sameSite: 'lax' — anti CSRF
|
||||
app.use(session({
|
||||
store: new FileStore({
|
||||
path: './sessions',
|
||||
ttl: 48 * 60 * 60,
|
||||
retries: 10,
|
||||
reapInterval: 3600
|
||||
}),
|
||||
secret: CONFIG.SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: 'auto',
|
||||
sameSite: 'lax',
|
||||
maxAge: 48 * 60 * 60 * 1000
|
||||
}
|
||||
}));
|
||||
|
||||
// ========================= ROUTES =========================
|
||||
|
||||
// Setup route (doit être AVANT le gatekeeper dans viewRoutes)
|
||||
app.use('/setup', setupRoutes);
|
||||
|
||||
// Routeur de vues EJS (inclut le setup gatekeeper)
|
||||
app.use('/', viewRoutes);
|
||||
|
||||
// Enregistrement des routes API
|
||||
app.use('/', authRoutes);
|
||||
app.use('/', apiRoutes);
|
||||
app.use('/', jdRoutes);
|
||||
app.use('/', proxyRoutes);
|
||||
|
||||
// Serve frontend static files
|
||||
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||
|
||||
// ========================= DÉMARRAGE =========================
|
||||
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(` Hydr'Hacked — API Server`);
|
||||
console.log(`${'='.repeat(60)}`);
|
||||
console.log(`Serveur API démarré sur http://localhost:${PORT}\n`);
|
||||
|
||||
// ---- Bootstrap auto du premier admin via .env ----
|
||||
if (!hasAnyUser()) {
|
||||
if (CONFIG.ADMIN_USERNAME && CONFIG.ADMIN_PASSWORD) {
|
||||
try {
|
||||
createUser(CONFIG.ADMIN_USERNAME, CONFIG.ADMIN_PASSWORD, 'admin');
|
||||
console.log(`[Auth] ✅ Admin auto-créé depuis .env: "${CONFIG.ADMIN_USERNAME}"`);
|
||||
} catch (error: any) {
|
||||
console.error(`[Auth] ❌ Erreur création admin auto:`, error.message);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Auth] ⚠️ Aucun utilisateur trouvé. Accédez à http://localhost:${PORT}/setup pour créer le premier admin.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-discovery : scan sources/, import, health check
|
||||
await discoverSources();
|
||||
|
||||
// Active les sources sauvegardées ou toutes les sources par défaut
|
||||
const { loadSettings } = await import('./utils/settingsManager.js');
|
||||
const settings = loadSettings();
|
||||
const availableSources = sourceRegistry.getAvailableNames();
|
||||
|
||||
if (settings && settings.activeSources && Array.isArray(settings.activeSources)) {
|
||||
// Filtre pour ne garder que les sources qui sont toujours valides/enregistrées
|
||||
globalState.activeSources = settings.activeSources.filter((s: string) => availableSources.includes(s));
|
||||
} else {
|
||||
// Aucune sauvegarde trouvée, on active tout par défaut
|
||||
globalState.activeSources = availableSources;
|
||||
}
|
||||
|
||||
console.log(`\nSource(s) par défaut: ${globalState.activeSources.map(s => s.toUpperCase()).join(', ') || 'Aucune'}`);
|
||||
|
||||
const scheduleNextCheck = () => {
|
||||
const randomMinutes = Math.floor(Math.random() * (CONFIG.MAX_MINUTES - CONFIG.MIN_MINUTES + 1)) + CONFIG.MIN_MINUTES;
|
||||
console.log(`[Timer] Prochaine vérification dans ${randomMinutes} minutes.`);
|
||||
setTimeout(async () => { await checkSiteStatus(); scheduleNextCheck(); }, randomMinutes * 60 * 1000);
|
||||
};
|
||||
|
||||
console.log("Lancement de la première vérification...");
|
||||
await checkSiteStatus();
|
||||
scheduleNextCheck();
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => { console.log('\nArrêt SIGINT...'); process.exit(0); });
|
||||
process.on('SIGTERM', () => { console.log('\nArrêt SIGTERM...'); process.exit(0); });
|
||||
@@ -0,0 +1,487 @@
|
||||
import express from 'express';
|
||||
import { globalState, getActiveSources, checkSiteStatus, rebuildTrendingFromCache } from '../utils/state.js';
|
||||
import { sourceRegistry } from '../core/registry.js';
|
||||
|
||||
import apiLimiter from '../utils/rateLimiter.js';
|
||||
import authMiddleware, { requireAdmin } from '../utils/authMiddleware.js';
|
||||
import { sendToJDownloader } from '../utils/jdownloader.js';
|
||||
import { MediaType, SearchResult } from '../types/source.js';
|
||||
import { CONFIG } from '../utils/config.js';
|
||||
import { getAllUsers, createUserWithGeneratedPassword, deleteUser, resetPassword, updateUserPreferences } from '../utils/userStore.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ========================= STATUS & CONFIG =========================
|
||||
|
||||
router.get('/api/status', apiLimiter, authMiddleware, (req, res) => {
|
||||
const labels: Record<string, string> = {};
|
||||
for (const source of sourceRegistry.getAllRegistered()) {
|
||||
labels[source.name] = source.displayName || source.name.toUpperCase();
|
||||
}
|
||||
res.json({
|
||||
isOffline: globalState.isSiteOffline,
|
||||
message: globalState.siteOfflineMessage,
|
||||
activeSources: globalState.activeSources,
|
||||
availableSources: sourceRegistry.getAvailableNames(),
|
||||
sourceLabels: labels
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/api/trending', apiLimiter, authMiddleware, (req, res) => {
|
||||
res.json({
|
||||
films: globalState.trendingFilms || [],
|
||||
series: globalState.trendingSeries || [],
|
||||
recent: globalState.recentItems || [],
|
||||
isOffline: globalState.isSiteOffline,
|
||||
message: globalState.siteOfflineMessage
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle sources
|
||||
router.post('/api/set-sources', apiLimiter, authMiddleware, async (req, res) => {
|
||||
const { sources } = req.body;
|
||||
if (!Array.isArray(sources)) {
|
||||
return res.status(400).json({ error: "Format invalide, un tableau de sources est attendu." });
|
||||
}
|
||||
|
||||
const validSources = sources.filter(s => sourceRegistry.has(s));
|
||||
|
||||
// Comparer les ensembles pour voir si l'ensemble des sources actives a changé (indépendamment de l'ordre)
|
||||
const oldSet = new Set(globalState.activeSources);
|
||||
const newSet = new Set(validSources);
|
||||
const hasSetChanged = oldSet.size !== newSet.size || [...oldSet].some(s => !newSet.has(s));
|
||||
|
||||
globalState.activeSources = validSources;
|
||||
console.log(`[Source] Sources actives mises à jour: ${validSources.map(s => s.toUpperCase()).join(', ')}`);
|
||||
|
||||
// Sauvegarde persistante des sources
|
||||
import('../utils/settingsManager.js').then(({ saveSettings }) => {
|
||||
saveSettings({ activeSources: validSources });
|
||||
});
|
||||
|
||||
if (hasSetChanged) {
|
||||
// Uniquement si l'ensemble a changé, lancer un scan réseau
|
||||
await checkSiteStatus();
|
||||
} else {
|
||||
// Si seul l'ordre a changé, reconstruire les tendances depuis le cache en mémoire
|
||||
rebuildTrendingFromCache();
|
||||
}
|
||||
|
||||
res.json({ success: true, activeSources: globalState.activeSources });
|
||||
});
|
||||
|
||||
// ========================= RECHERCHE =========================
|
||||
|
||||
router.post('/api/search', apiLimiter, authMiddleware, async (req, res) => {
|
||||
const {
|
||||
title,
|
||||
mediaType: rawTypeInput,
|
||||
type: typeInput,
|
||||
source: reqSource,
|
||||
src: reqSrc,
|
||||
sources: reqSources,
|
||||
mergeResults: reqMergeResults,
|
||||
mergeresult: reqMergeResult
|
||||
} = req.body;
|
||||
|
||||
// Handle both naming conventions and stringified booleans
|
||||
const mergeResults = (reqMergeResults !== undefined ? reqMergeResults : reqMergeResult) !== false &&
|
||||
(reqMergeResults !== 'false' && reqMergeResult !== 'false');
|
||||
|
||||
const rawType = rawTypeInput || typeInput || 'film';
|
||||
const mediaType = rawType === 'film' ? 'movie' : (rawType === 'serie' ? 'series' : rawType);
|
||||
|
||||
if (!title) return res.status(400).json({ error: "Titre manquant." });
|
||||
|
||||
// Determine which sources to query
|
||||
let sources = getActiveSources();
|
||||
const filterSources = reqSources || reqSource || reqSrc;
|
||||
|
||||
if (filterSources) {
|
||||
const targetNames = Array.isArray(filterSources) ? filterSources : [filterSources];
|
||||
sources = sources.filter(s => targetNames.includes(s.name));
|
||||
}
|
||||
|
||||
if (sources.length === 0) return res.status(500).json({ error: "Aucune source active correspondant à la demande." });
|
||||
|
||||
console.log(`\n--- Recherche [${sources.map(s => s.name.toUpperCase()).join(', ')}]: "${title}" (${mediaType}) ---`);
|
||||
|
||||
try {
|
||||
const resultsPromises = sources.map(async (source) => {
|
||||
try {
|
||||
const results = await source.search(title, mediaType as MediaType);
|
||||
return { sourceName: source.name, results };
|
||||
} catch (e: any) {
|
||||
console.error(`Erreur recherche sur ${source.name}:`, e.message);
|
||||
return { sourceName: source.name, error: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
const allResultsRaw = await Promise.all(resultsPromises);
|
||||
|
||||
if (mergeResults) {
|
||||
let allResults: SearchResult[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
allResultsRaw.forEach(item => {
|
||||
if (item.results) {
|
||||
allResults = allResults.concat(item.results);
|
||||
} else if (item.error) {
|
||||
errors.push(`${item.sourceName}: ${item.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!allResults.length) {
|
||||
if (errors.length > 0) {
|
||||
return res.status(500).json({ error: `Erreur(s): ${errors.join(', ')}` });
|
||||
}
|
||||
return res.status(404).json({ error: "Aucun résultat trouvé." });
|
||||
}
|
||||
res.json(allResults);
|
||||
} else {
|
||||
// Return grouped results
|
||||
const grouped: Record<string, SearchResult[] | { error: string }> = {};
|
||||
allResultsRaw.forEach(item => {
|
||||
grouped[item.sourceName] = item.results || { error: item.error! };
|
||||
});
|
||||
res.json(grouped);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Erreur /search:", error.message);
|
||||
res.status(500).json({ error: `Erreur serveur: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ========================= SÉLECTION =========================
|
||||
|
||||
const handleSelectContent: express.RequestHandler = async (req, res) => {
|
||||
const { hrefPath, title, type, source } = req.body;
|
||||
if (!hrefPath || !title || !source) return res.status(400).json({ error: "Données manquantes." });
|
||||
|
||||
const activeSource = sourceRegistry.get(source);
|
||||
if (!activeSource) return res.status(500).json({ error: `Source "${source}" introuvable ou inactive.` });
|
||||
|
||||
console.log(`\n--- Sélection [${activeSource.name.toUpperCase()}]: "${title}" ---`);
|
||||
|
||||
try {
|
||||
globalState.currentTitleName = title;
|
||||
globalState.currentIdentifier = hrefPath;
|
||||
globalState.currentSelectionSource = source; // On enregistre la source de cette sélection
|
||||
globalState.directUrlMap = {};
|
||||
|
||||
const selection = await activeSource.getSelection(hrefPath, type);
|
||||
|
||||
globalState.isSeries = selection.isSeries;
|
||||
globalState.currentLiens = selection.links;
|
||||
|
||||
selection.links.forEach((link: any, i: number) => {
|
||||
const key = link.id != null ? String(link.id) : String(i);
|
||||
if (link.url) globalState.directUrlMap[key] = link.url;
|
||||
});
|
||||
|
||||
res.json({
|
||||
clientOptions: selection.links,
|
||||
hasNextPage: false,
|
||||
seasons: selection.seasons
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Erreur sélection:", error.message);
|
||||
res.status(500).json({ error: `Erreur serveur: ${error.message}` });
|
||||
}
|
||||
};
|
||||
|
||||
router.post('/api/select-movie', apiLimiter, authMiddleware, handleSelectContent);
|
||||
router.post('/api/select-trending', apiLimiter, authMiddleware, handleSelectContent);
|
||||
|
||||
// ========================= GET LINK =========================
|
||||
|
||||
router.post('/api/get-link', apiLimiter, authMiddleware, async (req, res) => {
|
||||
if (req.body.chosenId == null) return res.status(400).json({ error: "ID manquant." });
|
||||
const chosenId = String(req.body.chosenId);
|
||||
const useJD = req.body.useJD !== false && req.body.useJD !== 'false';
|
||||
|
||||
const { currentTitleName, isSeries, directUrlMap, currentSelectionSource } = globalState;
|
||||
const activeSource = currentSelectionSource ? sourceRegistry.get(currentSelectionSource) : null;
|
||||
|
||||
console.log(`\n--- Get Link [${activeSource?.name.toUpperCase()}]: ID ${chosenId} pour "${currentTitleName}" (JD: ${useJD}) ---`);
|
||||
|
||||
try {
|
||||
let finalLink: string | null = null;
|
||||
|
||||
if (directUrlMap[chosenId]) {
|
||||
finalLink = directUrlMap[chosenId];
|
||||
} else if (activeSource?.resolveLink) {
|
||||
finalLink = await activeSource.resolveLink(chosenId);
|
||||
}
|
||||
|
||||
if (!finalLink) throw new Error("Impossible de résoudre le lien.");
|
||||
|
||||
console.log(`🎉 Lien final: ${finalLink}`);
|
||||
|
||||
if (useJD) {
|
||||
await sendToJDownloader(finalLink, currentTitleName || 'Unknown', isSeries);
|
||||
res.json({ status: 'succès', message: 'Lien envoyé à JDownloader !', link: finalLink });
|
||||
} else {
|
||||
res.json({ status: 'succès', message: 'Lien récupéré !', link: finalLink });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Erreur /get-link:", error.message);
|
||||
res.status(500).json({ error: `Erreur serveur: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ========================= GET LINKS BATCH =========================
|
||||
|
||||
router.post('/api/get-links-batch', apiLimiter, authMiddleware, async (req, res) => {
|
||||
const { chosenIds, useJD } = req.body;
|
||||
if (!chosenIds || !Array.isArray(chosenIds)) return res.status(400).json({ error: "Tableau d'IDs manquant." });
|
||||
|
||||
const { currentTitleName, isSeries, directUrlMap, currentSelectionSource } = globalState;
|
||||
const activeSource = currentSelectionSource ? sourceRegistry.get(currentSelectionSource) : null;
|
||||
|
||||
console.log(`\n--- Get Links Batch [${activeSource?.name.toUpperCase()}]: ${chosenIds.length} liens pour "${currentTitleName}" (JD: ${useJD !== false}) ---`);
|
||||
|
||||
try {
|
||||
const results: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const chosenId of chosenIds) {
|
||||
try {
|
||||
let finalLink: string | null = null;
|
||||
if (directUrlMap[String(chosenId)]) {
|
||||
finalLink = directUrlMap[String(chosenId)];
|
||||
} else if (activeSource?.resolveLink) {
|
||||
finalLink = await activeSource.resolveLink(String(chosenId));
|
||||
}
|
||||
|
||||
if (finalLink) {
|
||||
results.push(finalLink);
|
||||
if (useJD !== false && useJD !== 'false') {
|
||||
await sendToJDownloader(finalLink, currentTitleName || 'Unknown', isSeries);
|
||||
}
|
||||
} else {
|
||||
errors.push(`ID ${chosenId} introuvable.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
errors.push(`Erreur pour ID ${chosenId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return res.status(500).json({ error: "Aucun lien n'a pu être résolu.", details: errors });
|
||||
}
|
||||
|
||||
if (useJD !== false && useJD !== 'false') {
|
||||
res.json({ status: 'succès', message: `${results.length} lien(s) envoyé(s) à JDownloader !`, errors: errors.length > 0 ? errors : undefined });
|
||||
} else {
|
||||
res.json({ status: 'succès', message: `${results.length} lien(s) récupéré(s) !`, links: results, errors: errors.length > 0 ? errors : undefined });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Erreur /get-links-batch:", error.message);
|
||||
res.status(500).json({ error: `Erreur serveur: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ========================= MOVIEX DECODE =========================
|
||||
|
||||
router.get('/api/movix-decode/:lienId', apiLimiter, authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { lienId } = req.params;
|
||||
const sourceHydracker = sourceRegistry.get('hydracker') as any;
|
||||
|
||||
if (!sourceHydracker) {
|
||||
return res.status(400).json({ error: "Le plugin Hydracker n'est pas actif." });
|
||||
}
|
||||
|
||||
const link = await sourceHydracker.resolveMovixLink(lienId);
|
||||
|
||||
if (link) {
|
||||
res.json({ success: true, link });
|
||||
} else {
|
||||
res.status(404).json({ error: "Impossible de débrider ce lien via Movix." });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========================= SAISONS =========================
|
||||
|
||||
router.post('/api/select-season', apiLimiter, authMiddleware, async (req, res) => {
|
||||
const { seasonValue } = req.body;
|
||||
const activeSource = globalState.currentSelectionSource ? sourceRegistry.get(globalState.currentSelectionSource) : null;
|
||||
|
||||
console.log(`\n--- Changement de Saison [${activeSource?.name.toUpperCase()}] (${seasonValue}) ---`);
|
||||
|
||||
try {
|
||||
if (!activeSource) throw new Error("Aucune source active.");
|
||||
|
||||
const selection = await activeSource.getSelection(
|
||||
globalState.currentIdentifier!,
|
||||
undefined,
|
||||
seasonValue
|
||||
);
|
||||
|
||||
globalState.directUrlMap = {};
|
||||
selection.links.forEach((link: any, i: number) => {
|
||||
const key = link.id != null ? String(link.id) : String(i);
|
||||
if (link.url) globalState.directUrlMap[key] = link.url;
|
||||
});
|
||||
globalState.currentLiens = selection.links;
|
||||
|
||||
res.json({ clientOptions: selection.links, hasNextPage: false });
|
||||
} catch (error: any) {
|
||||
console.error("Erreur /select-season:", error.message);
|
||||
res.status(500).json({ error: "Erreur lors du changement de saison." });
|
||||
}
|
||||
});
|
||||
|
||||
// ========================= JD DOWNLOAD STATUS =========================
|
||||
|
||||
router.get('/api/download-status', apiLimiter, authMiddleware, async (req, res) => {
|
||||
const jdQuery = {
|
||||
params: [{ "running": true, "name": true, "bytesLoaded": true, "bytesTotal": true, "uuid": true, "packageUUID": true, "finished": true }],
|
||||
id: Date.now(), methodName: "queryLinks"
|
||||
};
|
||||
try {
|
||||
const response = await fetch(`http://${CONFIG.JD_HOST}:${CONFIG.JD_API_PORT}/downloadsV2/queryLinks`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(jdQuery)
|
||||
});
|
||||
if (!response.ok) throw new Error(`API JD non-OK: ${response.status}`);
|
||||
const data = await response.json();
|
||||
let items: any[] = [];
|
||||
if (data && data.data) {
|
||||
items = data.data.map((item: any) => {
|
||||
let percent = 0;
|
||||
if (item.bytesTotal > 0) percent = (item.bytesLoaded / item.bytesTotal) * 100;
|
||||
if (item.bytesLoaded > 0 && item.bytesLoaded === item.bytesTotal) percent = 100;
|
||||
return { name: item.name, percent, uuid: item.uuid, packageUUID: item.packageUUID, finished: item.finished || percent >= 100 };
|
||||
});
|
||||
}
|
||||
res.json(items);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ECONNREFUSED' || error.code === 'UND_ERR_CONNECT_TIMEOUT') { res.json([]); }
|
||||
else { res.status(500).json({ error: "Erreur API JDownloader" }); }
|
||||
}
|
||||
});
|
||||
|
||||
// Suppression d'un lien JDownloader
|
||||
router.post('/api/jd/remove-link', apiLimiter, authMiddleware, async (req, res) => {
|
||||
const { linkIds } = req.body;
|
||||
if (!linkIds || !linkIds.length) return res.status(400).json({ error: 'linkIds requis.' });
|
||||
try {
|
||||
const response = await fetch(`http://${CONFIG.JD_HOST}:${CONFIG.JD_API_PORT}/downloadsV2/removeLinks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ params: [linkIds, []] })
|
||||
});
|
||||
if (!response.ok) throw new Error(`API JD non-OK: ${response.status}`);
|
||||
console.log(`[JD] Suppression de ${linkIds.length} lien(s).`);
|
||||
res.json({ success: true, message: `${linkIds.length} lien(s) supprimé(s).` });
|
||||
} catch (error: any) {
|
||||
console.error('[JD] Erreur suppression:', error.message);
|
||||
res.status(500).json({ error: 'Erreur lors de la suppression JDownloader.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ========================= ADMIN — GESTION DES USERS =========================
|
||||
|
||||
/** GET /admin/users — Liste tous les utilisateurs (admin only) */
|
||||
router.get('/api/admin/users', apiLimiter, authMiddleware, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const users = getAllUsers();
|
||||
res.json(users);
|
||||
} catch (error: any) {
|
||||
console.error('[Admin] Erreur liste users:', error.message);
|
||||
res.status(500).json({ error: 'Erreur serveur.' });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /admin/users — Créer un utilisateur (admin only) */
|
||||
router.post('/api/admin/users', apiLimiter, authMiddleware, requireAdmin, (req, res) => {
|
||||
const { username, role } = req.body;
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
return res.status(400).json({ error: "Nom d'utilisateur requis." });
|
||||
}
|
||||
|
||||
const userRole = role === 'admin' ? 'admin' : 'user';
|
||||
|
||||
try {
|
||||
const result = createUserWithGeneratedPassword(username.trim(), userRole);
|
||||
console.log(`[Admin] Utilisateur créé par ${(req.session as any).user?.username}: ${result.user.username} (${userRole})`);
|
||||
res.json({
|
||||
success: true,
|
||||
user: result.user,
|
||||
generatedPassword: result.clearPassword,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Admin] Erreur création user:', error.message);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /admin/users/:id — Supprimer un utilisateur (admin only) */
|
||||
router.delete('/api/admin/users/:id', apiLimiter, authMiddleware, requireAdmin, (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const currentUser = (req.session as any).user;
|
||||
|
||||
// Interdire l'auto-suppression
|
||||
if (currentUser && currentUser.id === id) {
|
||||
return res.status(400).json({ error: 'Impossible de supprimer votre propre compte.' });
|
||||
}
|
||||
|
||||
try {
|
||||
deleteUser(id);
|
||||
console.log(`[Admin] Utilisateur supprimé par ${currentUser?.username}: ID ${id}`);
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('[Admin] Erreur suppression user:', error.message);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /admin/users/:id/reset-password — Reset le mot de passe (admin only) */
|
||||
router.post('/api/admin/users/:id/reset-password', apiLimiter, authMiddleware, requireAdmin, (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
|
||||
try {
|
||||
const result = resetPassword(id);
|
||||
console.log(`[Admin] Password reset par ${(req.session as any).user?.username} pour ID ${id}`);
|
||||
res.json({
|
||||
success: true,
|
||||
generatedPassword: result.clearPassword,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Admin] Erreur reset password:', error.message);
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// PREFERENCES
|
||||
// ============================================================
|
||||
|
||||
router.post('/api/preferences', apiLimiter, authMiddleware, (req, res) => {
|
||||
const session = req.session as any;
|
||||
const { key, value } = req.body;
|
||||
|
||||
if (!key || typeof key !== 'string') {
|
||||
return res.status(400).json({ error: "Clé de préférence manquante ou invalide." });
|
||||
}
|
||||
|
||||
try {
|
||||
updateUserPreferences(session.user.id, { [key]: value });
|
||||
// Mettre à jour la session en mémoire
|
||||
if (!session.user.preferences) session.user.preferences = {};
|
||||
session.user.preferences[key] = value;
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (e: any) {
|
||||
console.error('[API] Erreur sauvegarde préférence:', e.message);
|
||||
res.status(500).json({ error: "Impossible de sauvegarder la préférence." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,130 @@
|
||||
import express from 'express';
|
||||
import { verifyCredentials, changeUserPassword } from '../utils/userStore.js';
|
||||
import apiLimiter, { loginLimiter } from '../utils/rateLimiter.js';
|
||||
import authMiddleware from '../utils/authMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ============================================================
|
||||
// POST /login — Authentification username + password
|
||||
// ============================================================
|
||||
|
||||
router.post('/api/login', loginLimiter, (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Validation basique des inputs
|
||||
if (!username || typeof username !== 'string') {
|
||||
return res.status(400).json({ error: "Nom d'utilisateur manquant." });
|
||||
}
|
||||
if (!password || typeof password !== 'string') {
|
||||
return res.status(400).json({ error: "Mot de passe manquant." });
|
||||
}
|
||||
|
||||
// Limiter la taille du password pour éviter un DoS scrypt (max 128 chars)
|
||||
if (password.length > 128) {
|
||||
return res.status(400).json({ error: "Mot de passe trop long." });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = verifyCredentials(username, password);
|
||||
|
||||
if (user) {
|
||||
// Session regeneration — anti session fixation
|
||||
const oldSession = req.session as any;
|
||||
req.session.regenerate((err) => {
|
||||
if (err) {
|
||||
console.error('[Auth] Erreur session.regenerate:', err);
|
||||
return res.status(500).json({ error: "Erreur interne du serveur." });
|
||||
}
|
||||
|
||||
// Stocker les infos user dans la nouvelle session
|
||||
(req.session as any).user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
loginAt: new Date().toISOString(),
|
||||
mustChangePassword: user.mustChangePassword || false,
|
||||
preferences: user.preferences || {},
|
||||
};
|
||||
|
||||
console.log(`[Auth] Connexion réussie: ${user.username} (${user.role}) depuis ${req.ip}`);
|
||||
res.json({
|
||||
success: true,
|
||||
mustChangePassword: user.mustChangePassword || false,
|
||||
user: { username: user.username, role: user.role }
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Message générique — anti user-enumeration (jamais "utilisateur inconnu" vs "mauvais mdp")
|
||||
setTimeout(() => {
|
||||
console.warn(`[Auth] Tentative échouée pour "${username}" depuis ${req.ip}`);
|
||||
res.status(401).json({ error: "Identifiants invalides." });
|
||||
}, 500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Auth] Erreur login:', e);
|
||||
res.status(500).json({ error: "Erreur interne du serveur." });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// POST /change-password — Changement de mot de passe (obligatoire ou non)
|
||||
// ============================================================
|
||||
|
||||
router.post('/api/change-password', apiLimiter, authMiddleware, (req, res) => {
|
||||
const session = req.session as any;
|
||||
|
||||
const { newPassword } = req.body;
|
||||
if (!newPassword || typeof newPassword !== 'string') {
|
||||
return res.status(400).json({ error: "Le nouveau mot de passe est obligatoire." });
|
||||
}
|
||||
|
||||
try {
|
||||
changeUserPassword(session.user.id, newPassword);
|
||||
|
||||
// Mettre à jour la session
|
||||
session.user.mustChangePassword = false;
|
||||
// Important: Mettre à jour loginAt car le passwordChangedAt a changé, pour éviter d'invalider la session
|
||||
session.user.loginAt = new Date().toISOString();
|
||||
|
||||
console.log(`[Auth] Mot de passe changé avec succès pour ${session.user.username}`);
|
||||
res.json({ success: true, message: "Mot de passe modifié avec succès." });
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /check-session — Vérifie l'état de la session
|
||||
// ============================================================
|
||||
|
||||
router.get('/api/check-session', apiLimiter, (req, res) => {
|
||||
const session = req.session as any;
|
||||
if (session.user) {
|
||||
res.json({
|
||||
isLoggedIn: true,
|
||||
user: {
|
||||
username: session.user.username,
|
||||
role: session.user.role,
|
||||
mustChangePassword: session.user.mustChangePassword || false,
|
||||
preferences: session.user.preferences || {},
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.json({ isLoggedIn: false });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// POST /logout — Déconnexion
|
||||
// ============================================================
|
||||
|
||||
router.post('/api/logout', apiLimiter, (req, res) => {
|
||||
req.session.destroy(err => {
|
||||
if (err) return res.status(500).json({ error: "Échec de la déconnexion." });
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,38 @@
|
||||
import express from 'express';
|
||||
import { sendToJDownloader } from '../utils/jdownloader.js';
|
||||
import authMiddleware from '../utils/authMiddleware.js';
|
||||
import apiLimiter from '../utils/rateLimiter.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/api/jd/add', apiLimiter, authMiddleware, async (req, res) => {
|
||||
const { link, links, packageName, isSeries } = req.body;
|
||||
|
||||
let list: string[] = [];
|
||||
if (Array.isArray(links)) {
|
||||
list = links.map(l => l.trim()).filter(Boolean);
|
||||
} else if (typeof links === 'string') {
|
||||
list = links.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
}
|
||||
if (link && typeof link === 'string') {
|
||||
const trimmed = link.trim();
|
||||
if (trimmed && !list.includes(trimmed)) {
|
||||
list.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
return res.status(400).json({ error: "Aucun lien valide fourni." });
|
||||
}
|
||||
|
||||
try {
|
||||
for (const url of list) {
|
||||
await sendToJDownloader(url, packageName || 'Manual Add', !!isSeries);
|
||||
}
|
||||
res.json({ success: true, count: list.length });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,60 @@
|
||||
import express from 'express';
|
||||
import authMiddleware from '../utils/authMiddleware.js';
|
||||
import apiLimiter from '../utils/rateLimiter.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function isBlockedHost(hostname: string) {
|
||||
if (hostname === 'localhost' || hostname.endsWith('.local')) return true;
|
||||
if (hostname === '[::1]' || hostname === '::1') return true;
|
||||
|
||||
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
const match = hostname.match(ipv4Regex);
|
||||
if (match) {
|
||||
const p1 = parseInt(match[1]);
|
||||
const p2 = parseInt(match[2]);
|
||||
if (p1 === 10) return true; // 10.x.x.x
|
||||
if (p1 === 127) return true; // 127.x.x.x
|
||||
if (p1 === 192 && p2 === 168) return true; // 192.168.x.x
|
||||
if (p1 === 172 && p2 >= 16 && p2 <= 31) return true; // 172.16.x.x - 172.31.x.x
|
||||
if (p1 === 169 && p2 === 254) return true; // APIPA
|
||||
if (p1 === 0) return true; // 0.0.0.0
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
router.get('/api/proxy-image', apiLimiter, authMiddleware, async (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url || typeof url !== 'string') return res.status(400).send('URL manquante ou invalide');
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// 1. Vérification du protocole
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
return res.status(403).send('Protocole non autorisé');
|
||||
}
|
||||
|
||||
// 2. Vérification de l'hôte (Black Liste IPs privées / localhost)
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
if (isBlockedHost(hostname)) {
|
||||
console.warn(`[Proxy] Tentative bloquée (SSRF) pour l'hôte local ou privé : ${hostname}`);
|
||||
return res.status(403).send('Hôte non autorisé pour le proxy');
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg';
|
||||
|
||||
res.set('Content-Type', contentType);
|
||||
res.set('Cache-Control', 'public, max-age=86400'); // 24h cache
|
||||
res.send(buffer);
|
||||
} catch (error: any) {
|
||||
res.status(500).send('Erreur lors du chargement de l\'image');
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,66 @@
|
||||
import express from 'express';
|
||||
import { hasAnyUser, createUser } from '../utils/userStore.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ============================================================
|
||||
// GET /setup — Page de création du premier administrateur
|
||||
// ============================================================
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
// Sécurité : si des users existent déjà, pas d'accès au setup
|
||||
if (hasAnyUser()) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
res.render('setup', { error: null });
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// POST /setup — Création du premier admin
|
||||
// ============================================================
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
// Sécurité : si des users existent déjà, bloquer
|
||||
if (hasAnyUser()) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
const { username, password, confirmPassword } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!username || !password) {
|
||||
return res.render('setup', { error: "Tous les champs sont requis." });
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return res.render('setup', { error: "Les mots de passe ne correspondent pas." });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = createUser(username, password, 'admin');
|
||||
|
||||
// Connecter automatiquement après setup
|
||||
req.session.regenerate((err) => {
|
||||
if (err) {
|
||||
console.error('[Setup] Erreur session.regenerate:', err);
|
||||
return res.render('setup', { error: "Erreur interne. Réessayez." });
|
||||
}
|
||||
|
||||
(req.session as any).user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
loginAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log(`[Setup] Premier admin créé: ${user.username}`);
|
||||
res.redirect('/');
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Setup] Erreur création admin:', error.message);
|
||||
res.render('setup', { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,96 @@
|
||||
import express from 'express';
|
||||
import { viewAuthMiddleware, viewRequireAdmin } from '../utils/authMiddleware.js';
|
||||
import { hasAnyUser } from '../utils/userStore.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ============================================================
|
||||
// Setup Gatekeeper — Redirige vers /setup si aucun user n'existe
|
||||
// ============================================================
|
||||
|
||||
const setupGatekeeper = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
// Laisser passer les routes de setup et les assets statiques
|
||||
if (req.path === '/setup' || req.path.startsWith('/setup/') ||
|
||||
req.path === '/login' || req.path.startsWith('/login') ||
|
||||
req.path.startsWith('/images') || req.path.startsWith('/style') ||
|
||||
req.path.startsWith('/app.') || req.path.startsWith('/lucide') ||
|
||||
req.path.startsWith('/sw.') || req.path.startsWith('/manifest')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!hasAnyUser()) {
|
||||
return res.redirect('/setup');
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// Appliquer le gatekeeper à toutes les routes de vue
|
||||
router.use(setupGatekeeper);
|
||||
|
||||
// ============================================================
|
||||
// Routes publiques
|
||||
// ============================================================
|
||||
|
||||
router.get('/login', (req, res) => {
|
||||
if ((req.session as any).user) {
|
||||
const defaultPage = req.cookies?.defaultPage || '/trending';
|
||||
const allowedPages = ['/trending', '/recent', '/search', '/downloads', '/manual', '/settings'];
|
||||
if (allowedPages.includes(defaultPage)) {
|
||||
res.redirect(defaultPage);
|
||||
} else {
|
||||
res.redirect('/trending');
|
||||
}
|
||||
} else {
|
||||
res.render('login');
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Routes protégées — Injecter les infos user dans les vues
|
||||
// ============================================================
|
||||
|
||||
const protectedRoute = (viewName: string, page: string) => {
|
||||
return [viewAuthMiddleware, (req: express.Request, res: express.Response) => {
|
||||
const session = req.session as any;
|
||||
res.render(viewName, {
|
||||
page,
|
||||
currentUser: session.user || null,
|
||||
});
|
||||
}] as express.RequestHandler[];
|
||||
};
|
||||
|
||||
router.get('/trending', ...protectedRoute('trending', 'trending'));
|
||||
router.get('/recent', ...protectedRoute('recent', 'recent'));
|
||||
router.get('/search', ...protectedRoute('search', 'search'));
|
||||
router.get('/downloads', ...protectedRoute('downloads', 'downloads'));
|
||||
router.get('/manual', ...protectedRoute('manual', 'manual'));
|
||||
router.get('/settings', viewAuthMiddleware, viewRequireAdmin, (req, res) => {
|
||||
const session = req.session as any;
|
||||
res.render('settings', {
|
||||
page: 'settings',
|
||||
currentUser: session.user || null,
|
||||
});
|
||||
});
|
||||
|
||||
// Admin endpoint redirects to settings
|
||||
router.get('/admin', viewAuthMiddleware, (req, res) => {
|
||||
res.redirect('/settings');
|
||||
});
|
||||
|
||||
// Home endpoint redirects depending on authentication status
|
||||
router.get('/', (req, res) => {
|
||||
if ((req.session as any).user) {
|
||||
const defaultPage = req.cookies?.defaultPage || '/trending';
|
||||
const allowedPages = ['/trending', '/recent', '/search', '/downloads', '/manual', '/settings'];
|
||||
if (allowedPages.includes(defaultPage)) {
|
||||
res.redirect(defaultPage);
|
||||
} else {
|
||||
res.redirect('/trending');
|
||||
}
|
||||
} else {
|
||||
res.redirect('/login');
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,74 @@
|
||||
export type MediaType = 'movie' | 'series' | 'anime' | 'book' | 'game' | 'software' | 'music' | 'other';
|
||||
|
||||
export interface SearchResult {
|
||||
title: string;
|
||||
year: string | null;
|
||||
image: string | null;
|
||||
hrefPath: string; // The identifier or path for the source
|
||||
type: MediaType;
|
||||
source: string; // 'zt' | 'hydracker'
|
||||
hydrackerId?: string; // Specific to Hydracker
|
||||
}
|
||||
|
||||
export interface VideoLink {
|
||||
id: string | number;
|
||||
host: string;
|
||||
label?: string;
|
||||
url: string | null; // Final direct URL if available
|
||||
size?: string;
|
||||
sizeBytes?: number;
|
||||
quality?: string;
|
||||
langs?: string[];
|
||||
subs?: string[];
|
||||
releaseName?: string;
|
||||
episode?: string | null;
|
||||
}
|
||||
|
||||
export interface ContentLinks {
|
||||
links: VideoLink[];
|
||||
releaseNames?: string[];
|
||||
relatedSeasons?: { href: string; label: string }[];
|
||||
relatedQualities?: { href: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface SeasonOption {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export interface SelectionData {
|
||||
links: VideoLink[];
|
||||
seasons: SeasonOption[];
|
||||
isSeries: boolean;
|
||||
}
|
||||
|
||||
export interface ISource {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
search(query: string, mediaType?: MediaType): Promise<SearchResult[]>;
|
||||
getTrending(mediaType: MediaType): Promise<SearchResult[]>;
|
||||
getRecent?(): Promise<SearchResult[]>;
|
||||
getContentLinks(identifier: string, season?: number): Promise<ContentLinks>;
|
||||
|
||||
|
||||
/**
|
||||
* Vérifie si la source est utilisable (config valide + connectivité).
|
||||
* Appelé au démarrage par le registry. Seules les sources qui retournent true sont activées.
|
||||
*/
|
||||
healthCheck(): Promise<boolean>;
|
||||
|
||||
// Unified selection method
|
||||
getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData>;
|
||||
|
||||
/**
|
||||
* Résout un lien vers son URL finale téléchargeable.
|
||||
* Implémenté par les sources qui nécessitent une résolution en 2 étapes
|
||||
* (ex: Hydracker où l'ID doit être résolu via une API premium).
|
||||
* Les sources avec des URLs directes (ex: ZT) n'ont pas besoin de l'implémenter.
|
||||
*/
|
||||
resolveLink?(linkId: string): Promise<string | null>;
|
||||
|
||||
// Optional methods that might be source-specific but useful to standardize
|
||||
getSeasons?(identifier: string): Promise<number[]>;
|
||||
getEpisodes?(identifier: string, season: number): Promise<any[]>;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { getUserById } from './userStore.js';
|
||||
|
||||
// ============================================================
|
||||
// Auth Middleware — Session-based avec vérification passwordChangedAt
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Middleware d'authentification.
|
||||
* Vérifie que l'utilisateur est connecté ET que sa session n'a pas été
|
||||
* invalidée par un changement de mot de passe.
|
||||
*/
|
||||
const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
const session = req.session as any;
|
||||
|
||||
if (!session.user || !session.user.id) {
|
||||
return res.status(401).json({ error: "Non autorisé. Veuillez vous connecter." });
|
||||
}
|
||||
|
||||
// Bloquer les APIs si changement de mot de passe requis
|
||||
if (session.user.mustChangePassword && req.path !== '/api/change-password' && req.path !== '/api/logout') {
|
||||
return res.status(403).json({ error: "Changement de mot de passe obligatoire.", mustChangePassword: true });
|
||||
}
|
||||
|
||||
// Vérifier que le user existe encore et que le password n'a pas changé depuis le login
|
||||
const user = getUserById(session.user.id);
|
||||
if (!user) {
|
||||
// User supprimé depuis le login → détruire la session
|
||||
session.destroy(() => {});
|
||||
return res.status(401).json({ error: "Session invalide. Veuillez vous reconnecter." });
|
||||
}
|
||||
|
||||
// Comparer loginAt avec passwordChangedAt — si le password a changé après le login, invalider
|
||||
if (session.user.loginAt && user.passwordChangedAt) {
|
||||
const loginTime = new Date(session.user.loginAt).getTime();
|
||||
const passwordChangeTime = new Date(user.passwordChangedAt).getTime();
|
||||
if (passwordChangeTime > loginTime) {
|
||||
session.destroy(() => {});
|
||||
return res.status(401).json({ error: "Votre mot de passe a été modifié. Veuillez vous reconnecter." });
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware admin.
|
||||
* Doit être utilisé APRÈS authMiddleware (session.user déjà vérifié).
|
||||
* Vérifie que l'utilisateur a le rôle admin.
|
||||
*/
|
||||
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
const session = req.session as any;
|
||||
|
||||
if (!session.user || session.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: "Accès refusé. Droits administrateur requis." });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware de vue d'authentification.
|
||||
* Identique à authMiddleware mais redirige vers /login au lieu de retourner du JSON.
|
||||
*/
|
||||
export const viewAuthMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
const session = req.session as any;
|
||||
|
||||
if (!session.user || !session.user.id) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
// Même vérification passwordChangedAt
|
||||
const user = getUserById(session.user.id);
|
||||
if (!user) {
|
||||
session.destroy(() => {});
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
if (session.user.loginAt && user.passwordChangedAt) {
|
||||
const loginTime = new Date(session.user.loginAt).getTime();
|
||||
const passwordChangeTime = new Date(user.passwordChangedAt).getTime();
|
||||
if (passwordChangeTime > loginTime) {
|
||||
session.destroy(() => {});
|
||||
return res.redirect('/login');
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware de vue exigeant le rôle admin.
|
||||
* Redirige les non-admins vers /trending en déposant un cookie d'erreur.
|
||||
*/
|
||||
export const viewRequireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
const session = req.session as any;
|
||||
|
||||
if (!session.user || session.user.role !== 'admin') {
|
||||
res.cookie('authError', "Accès refusé. Droits administrateur requis.", { maxAge: 10000 });
|
||||
return res.redirect('/trending');
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export default authMiddleware;
|
||||
@@ -0,0 +1,41 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
const DEV_SECRET = 'hydracked-secret-key-12345';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (isProd && (!process.env.SECRET || process.env.SECRET === DEV_SECRET)) {
|
||||
throw new Error('[CONFIG] SECRET requis et différent du fallback dev en production.');
|
||||
}
|
||||
|
||||
export const CONFIG = {
|
||||
// Plugin ZT (par défaut)
|
||||
ZT_URL: process.env.ZT_URL,
|
||||
ZTTEAM_URL: process.env.ZTTEAM_URL,
|
||||
FT_URL: process.env.FT_URL,
|
||||
|
||||
// Plugin Hydracker (optionnel)
|
||||
HYDRACKER_URL: process.env.HYDRACKER_URL || process.env.BASE_URL,
|
||||
HYDRACKER_API_KEY: process.env.HYDRACKER_API_KEY || process.env.API_KEY,
|
||||
HYDRACKER_TIMEOUT: parseInt(process.env.HYDRACKER_TIMEOUT || '30000', 10),
|
||||
|
||||
// Local Database (optionnel)
|
||||
DB_PATH: process.env.DB_PATH || './database/darkiworld.db',
|
||||
|
||||
// App — Auth bootstrap (optionnel, pour création auto du premier admin)
|
||||
ADMIN_USERNAME: process.env.ADMIN_USERNAME,
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
|
||||
|
||||
// App
|
||||
JD_HOST: process.env.JD_HOST?.trim(),
|
||||
JD_API_PORT: process.env.JD_API_PORT?.trim(),
|
||||
PATHS_JD_SERIES: process.env.PATHS_JD_SERIES,
|
||||
PATHS_JD_FILMS: process.env.PATHS_JD_FILMS,
|
||||
PATHS_JD_WATCH: process.env.PATHS_JD_WATCH,
|
||||
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),
|
||||
PORT: parseInt(process.env.PORT || '3067', 10),
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { CONFIG } from './config.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// --- JDownloader ---
|
||||
export async function sendToJDownloader(link: string, titleName: string, isSeries: boolean = false) {
|
||||
if (!CONFIG.PATHS_JD_WATCH) {
|
||||
console.error("Erreur JDownloader: PATHS_JD_WATCH non configuré.");
|
||||
return;
|
||||
}
|
||||
if (!CONFIG.PATHS_JD_FILMS) {
|
||||
console.error("Erreur JDownloader: PATHS_JD_FILMS non configuré.");
|
||||
return;
|
||||
}
|
||||
if (!CONFIG.PATHS_JD_SERIES) {
|
||||
console.error("Erreur JDownloader: PATHS_JD_SERIES non configuré.");
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = `link_${Date.now()}.crawljob`;
|
||||
const filePath = path.join(CONFIG.PATHS_JD_WATCH, fileName);
|
||||
const lineEnding = '\n';
|
||||
|
||||
const safeLink = link.trim() + "#movie.mkv";
|
||||
|
||||
const autoStartStr = CONFIG.JD_AUTOSTART ? 'TRUE' : 'FALSE';
|
||||
let fileContent = `text=${safeLink}${lineEnding}`;
|
||||
fileContent += `enabled=TRUE${lineEnding}`;
|
||||
fileContent += `autoStart=${autoStartStr}${lineEnding}`;
|
||||
fileContent += `forcedStart=${autoStartStr}${lineEnding}`;
|
||||
fileContent += `deepAnalyse=TRUE${lineEnding}`;
|
||||
fileContent += `autoConfirm=TRUE${lineEnding}`;
|
||||
fileContent += `overwritePackagizerEnabled=TRUE${lineEnding}`;
|
||||
|
||||
if (titleName) {
|
||||
const safeTitle = titleName.replace(/[\r\n<>:"/\\|?*]+/g, '').replace(/\.$/, '').trim();
|
||||
fileContent += `packageName=${safeTitle}${lineEnding}`;
|
||||
|
||||
if (isSeries) {
|
||||
console.log(`Série (${titleName}), configuration chemin JD...`);
|
||||
const rawSeriesFolder = CONFIG.JD_CREATE_SUBFOLDER ? `${CONFIG.PATHS_JD_SERIES}${safeTitle}` : `${CONFIG.PATHS_JD_SERIES}`;
|
||||
const seriesDownloadFolder = rawSeriesFolder.replace(/\\/g, '\\\\');
|
||||
fileContent += `downloadFolder=${seriesDownloadFolder}${lineEnding}`;
|
||||
console.log(` -> DownloadFolder: ${seriesDownloadFolder}`);
|
||||
} else {
|
||||
const rawFilmFolder = CONFIG.JD_CREATE_SUBFOLDER ? `${CONFIG.PATHS_JD_FILMS}${safeTitle}` : `${CONFIG.PATHS_JD_FILMS}`;
|
||||
const filmDownloadFolder = rawFilmFolder.replace(/\\/g, '\\\\');
|
||||
fileContent += `downloadFolder=${filmDownloadFolder}${lineEnding}`;
|
||||
console.log(` -> DownloadFolder: ${filmDownloadFolder}`);
|
||||
}
|
||||
console.log(` -> PackageName: ${safeTitle}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(filePath, fileContent);
|
||||
await fs.promises.chmod(filePath, 0o666);
|
||||
try {
|
||||
await fs.promises.chown(filePath, 1000, 1000);
|
||||
} catch (e) {
|
||||
console.log("Note: Impossible de changer le propriétaire (chown).");
|
||||
}
|
||||
console.log(`✅ Fichier .crawljob (${fileName}) créé.`);
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Erreur JDownloader (${fileName}):`, error.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 1000, // Limite chaque IP à 200 requêtes par fenêtre
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
error: "Trop de requêtes, veuillez réessayer plus tard."
|
||||
}
|
||||
});
|
||||
|
||||
export const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // 5 tentatives échouées max par IP par fenêtre
|
||||
skipSuccessfulRequests: true,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
error: "Trop de tentatives. Réessayez dans 15 minutes."
|
||||
}
|
||||
});
|
||||
|
||||
export default apiLimiter;
|
||||
@@ -0,0 +1,35 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const SETTINGS_DIR = path.join(process.cwd(), 'database');
|
||||
const SETTINGS_FILE = path.join(SETTINGS_DIR, 'settings.json');
|
||||
|
||||
export interface AppSettings {
|
||||
activeSources: string[];
|
||||
}
|
||||
|
||||
export function loadSettings(): AppSettings | null {
|
||||
try {
|
||||
if (!fs.existsSync(SETTINGS_FILE)) return null;
|
||||
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (error: any) {
|
||||
console.error('[Settings] Erreur lors du chargement des paramètres:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(settings: Partial<AppSettings>) {
|
||||
try {
|
||||
if (!fs.existsSync(SETTINGS_DIR)) {
|
||||
fs.mkdirSync(SETTINGS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const currentSettings = loadSettings() || { activeSources: [] };
|
||||
const newSettings = { ...currentSettings, ...settings };
|
||||
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(newSettings, null, 2), 'utf8');
|
||||
} catch (error: any) {
|
||||
console.error('[Settings] Erreur lors de la sauvegarde des paramètres:', error.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { sourceRegistry } from '../core/registry.js';
|
||||
import { ISource, SearchResult } from '../types/source.js';
|
||||
|
||||
export interface GlobalState {
|
||||
currentTitleId: string | null;
|
||||
currentTitleName: string | null;
|
||||
currentIdentifier: string | null; // hrefPath du contenu sélectionné (source-agnostic)
|
||||
currentSelectionSource: string | null; // Nom de la source ayant fourni le contenu sélectionné
|
||||
currentLiens: any[];
|
||||
directUrlMap: Record<string, string>;
|
||||
isSeries: boolean;
|
||||
activeSources: string[]; // Liste des sources actives
|
||||
isSiteOffline: boolean;
|
||||
siteOfflineMessage: string;
|
||||
isCheckingStatus: boolean;
|
||||
trendingFilms: SearchResult[];
|
||||
trendingSeries: SearchResult[];
|
||||
recentItems: SearchResult[];
|
||||
}
|
||||
|
||||
export const globalState: GlobalState = {
|
||||
currentTitleId: null,
|
||||
currentTitleName: null,
|
||||
currentIdentifier: null,
|
||||
currentSelectionSource: null,
|
||||
currentLiens: [],
|
||||
directUrlMap: {},
|
||||
isSeries: false,
|
||||
activeSources: [],
|
||||
isSiteOffline: true,
|
||||
siteOfflineMessage: "Vérification du statut du site en cours...",
|
||||
isCheckingStatus: false,
|
||||
trendingFilms: [],
|
||||
trendingSeries: [],
|
||||
recentItems: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Retourne les instances des sources actives via le registry.
|
||||
*/
|
||||
export function getActiveSources(): ISource[] {
|
||||
return globalState.activeSources
|
||||
.map(name => sourceRegistry.get(name))
|
||||
.filter((source): source is ISource => source !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le statut du site (offline/online) et charge les tendances de toutes les sources actives.
|
||||
*/
|
||||
export async function checkSiteStatus() {
|
||||
if (globalState.isCheckingStatus) return;
|
||||
globalState.isCheckingStatus = true;
|
||||
|
||||
const sources = getActiveSources();
|
||||
if (sources.length === 0) {
|
||||
globalState.isSiteOffline = true;
|
||||
globalState.siteOfflineMessage = "Aucune source configurée.";
|
||||
globalState.trendingFilms = [];
|
||||
globalState.trendingSeries = [];
|
||||
globalState.recentItems = [];
|
||||
globalState.isCheckingStatus = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Vérification] Test de ${sources.length} sources actives...`);
|
||||
|
||||
let allFilms: SearchResult[] = [];
|
||||
let allSeries: SearchResult[] = [];
|
||||
let allRecent: SearchResult[] = [];
|
||||
let onlineSourcesCount = 0;
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(sources.map(async (source) => {
|
||||
const films = await source.getTrending('movie');
|
||||
const series = await source.getTrending('series');
|
||||
const recent = source.getRecent ? await source.getRecent() : [];
|
||||
return { source, films, series, recent };
|
||||
}));
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
const { source, films, series, recent } = result.value;
|
||||
onlineSourcesCount++;
|
||||
|
||||
// Mettre en cache les tendances de cette source
|
||||
sourceTrendsCache.set(source.name, { films: films || [], series: series || [], recent: recent || [] });
|
||||
|
||||
if (films && films.length > 0) allFilms = allFilms.concat(films);
|
||||
if (series && series.length > 0) allSeries = allSeries.concat(series);
|
||||
if (recent && recent.length > 0) allRecent = allRecent.concat(recent);
|
||||
|
||||
console.log(`[${source.name.toUpperCase()}] ${films?.length || 0} films, ${series?.length || 0} séries, ${recent?.length || 0} récents.`);
|
||||
} else if (result.status === 'rejected') {
|
||||
console.error(`[Erreur] Source indisponible: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Déduplication par titre pour éviter les doublons entre les sources
|
||||
const deduplicate = (items: SearchResult[]) => {
|
||||
const seen = new Set<string>();
|
||||
const unique: SearchResult[] = [];
|
||||
for (const item of items) {
|
||||
const key = item.title.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(item);
|
||||
}
|
||||
}
|
||||
return unique;
|
||||
};
|
||||
|
||||
globalState.trendingFilms = deduplicate(allFilms);
|
||||
globalState.trendingSeries = deduplicate(allSeries);
|
||||
globalState.recentItems = deduplicate(allRecent);
|
||||
|
||||
if (onlineSourcesCount > 0) {
|
||||
globalState.isSiteOffline = false;
|
||||
globalState.siteOfflineMessage = "";
|
||||
} else {
|
||||
globalState.isSiteOffline = true;
|
||||
globalState.siteOfflineMessage = "Toutes les sources actives sont indisponibles.";
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[ERREUR FATALE] ${error.message}`);
|
||||
globalState.isSiteOffline = true;
|
||||
globalState.siteOfflineMessage = "Erreur lors de la vérification des sources.";
|
||||
} finally {
|
||||
globalState.isCheckingStatus = false;
|
||||
console.log("[Vérification] Terminée.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache interne pour stocker les tendances de chaque source réussie.
|
||||
*/
|
||||
const sourceTrendsCache = new Map<string, { films: SearchResult[], series: SearchResult[], recent: SearchResult[] }>();
|
||||
|
||||
/**
|
||||
* Reconstruit globalState.trendingFilms, trendingSeries et recentItems à partir du cache
|
||||
* en respectant l'ordre de priorité défini dans globalState.activeSources.
|
||||
*/
|
||||
export function rebuildTrendingFromCache() {
|
||||
let allFilms: SearchResult[] = [];
|
||||
let allSeries: SearchResult[] = [];
|
||||
let allRecent: SearchResult[] = [];
|
||||
|
||||
const sources = getActiveSources();
|
||||
for (const source of sources) {
|
||||
const cached = sourceTrendsCache.get(source.name);
|
||||
if (cached) {
|
||||
if (cached.films && cached.films.length > 0) allFilms = allFilms.concat(cached.films);
|
||||
if (cached.series && cached.series.length > 0) allSeries = allSeries.concat(cached.series);
|
||||
if (cached.recent && cached.recent.length > 0) allRecent = allRecent.concat(cached.recent);
|
||||
}
|
||||
}
|
||||
|
||||
const deduplicate = (items: SearchResult[]) => {
|
||||
const seen = new Set<string>();
|
||||
const unique: SearchResult[] = [];
|
||||
for (const item of items) {
|
||||
const key = item.title.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(item);
|
||||
}
|
||||
}
|
||||
return unique;
|
||||
};
|
||||
|
||||
globalState.trendingFilms = deduplicate(allFilms);
|
||||
globalState.trendingSeries = deduplicate(allSeries);
|
||||
globalState.recentItems = deduplicate(allRecent);
|
||||
console.log(`[Cache] Tendances reconstruites en mémoire pour ${sources.length} sources actives.`);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// ============================================================
|
||||
// User Store — Fichier JSON avec hashing scrypt
|
||||
// ============================================================
|
||||
|
||||
const USERS_DIR = path.join(process.cwd(), 'database');
|
||||
const USERS_FILE = path.join(USERS_DIR, 'users.json');
|
||||
|
||||
// Scrypt parameters (NIST SP 800-132 / OWASP recommendations)
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
const SCRYPT_SALT_LEN = 32;
|
||||
const SCRYPT_OPTIONS: crypto.ScryptOptions = {
|
||||
N: 16384, // CPU/memory cost (2^14)
|
||||
r: 8, // Block size
|
||||
p: 1, // Parallelism
|
||||
};
|
||||
|
||||
// Input validation constraints
|
||||
const USERNAME_MIN = 3;
|
||||
const USERNAME_MAX = 32;
|
||||
const USERNAME_REGEX = /^[a-zA-Z0-9_.-]+$/;
|
||||
const PASSWORD_MIN = 8;
|
||||
const PASSWORD_MAX = 128;
|
||||
|
||||
// Password generation charset
|
||||
const PASSWORD_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&*_+-=';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string; // format: "salt_hex:hash_hex"
|
||||
role: 'admin' | 'user';
|
||||
createdAt: string; // ISO 8601
|
||||
passwordChangedAt: string; // ISO 8601 — pour invalidation session post-reset
|
||||
mustChangePassword?: boolean;
|
||||
preferences?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Données publiques (jamais le hash)
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
createdAt: string;
|
||||
mustChangePassword?: boolean;
|
||||
preferences?: Record<string, any>;
|
||||
}
|
||||
|
||||
// ========================= INTERNAL HELPERS =========================
|
||||
|
||||
function loadUsers(): User[] {
|
||||
try {
|
||||
if (!fs.existsSync(USERS_FILE)) return [];
|
||||
const data = fs.readFileSync(USERS_FILE, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (error: any) {
|
||||
console.error('[UserStore] Erreur lecture users.json:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveUsers(users: User[]): void {
|
||||
try {
|
||||
if (!fs.existsSync(USERS_DIR)) {
|
||||
fs.mkdirSync(USERS_DIR, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2), 'utf8');
|
||||
} catch (error: any) {
|
||||
console.error('[UserStore] Erreur écriture users.json:', error.message);
|
||||
throw new Error('Impossible de sauvegarder les utilisateurs.');
|
||||
}
|
||||
}
|
||||
|
||||
function hashPassword(password: string, salt: Buffer): string {
|
||||
const hash = crypto.scryptSync(password, salt, SCRYPT_KEYLEN, SCRYPT_OPTIONS);
|
||||
return `${salt.toString('hex')}:${hash.toString('hex')}`;
|
||||
}
|
||||
|
||||
function verifyPasswordHash(password: string, stored: string): boolean {
|
||||
const [saltHex, hashHex] = stored.split(':');
|
||||
if (!saltHex || !hashHex) return false;
|
||||
|
||||
const salt = Buffer.from(saltHex, 'hex');
|
||||
const storedHash = Buffer.from(hashHex, 'hex');
|
||||
const computedHash = crypto.scryptSync(password, salt, SCRYPT_KEYLEN, SCRYPT_OPTIONS);
|
||||
|
||||
// Vérification en temps constant — anti timing attack
|
||||
if (storedHash.length !== computedHash.length) return false;
|
||||
return crypto.timingSafeEqual(storedHash, computedHash);
|
||||
}
|
||||
|
||||
/** Génère un mot de passe aléatoire cryptographiquement sûr et conforme aux exigences */
|
||||
function generatePassword(length: number = 16): string {
|
||||
while (true) {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let password = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += PASSWORD_CHARS[bytes[i] % PASSWORD_CHARS.length];
|
||||
}
|
||||
if (validatePassword(password) === null) {
|
||||
return password;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Exécute un scrypt bidon pour uniformiser le temps de réponse (anti user-enumeration) */
|
||||
export function dummyScrypt(): void {
|
||||
const fakeSalt = crypto.randomBytes(SCRYPT_SALT_LEN);
|
||||
crypto.scryptSync('dummy-password-for-timing', fakeSalt, SCRYPT_KEYLEN, SCRYPT_OPTIONS);
|
||||
}
|
||||
|
||||
// ========================= VALIDATION =========================
|
||||
|
||||
function validateUsername(username: string): string | null {
|
||||
if (!username || typeof username !== 'string') return 'Nom d\'utilisateur requis.';
|
||||
const trimmed = username.trim();
|
||||
if (trimmed.length < USERNAME_MIN) return `Nom d'utilisateur trop court (min ${USERNAME_MIN} caractères).`;
|
||||
if (trimmed.length > USERNAME_MAX) return `Nom d'utilisateur trop long (max ${USERNAME_MAX} caractères).`;
|
||||
if (!USERNAME_REGEX.test(trimmed)) return 'Nom d\'utilisateur invalide (lettres, chiffres, _ . - uniquement).';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validatePassword(password: string): string | null {
|
||||
if (!password || typeof password !== 'string') return 'Mot de passe requis.';
|
||||
if (password.length < PASSWORD_MIN) return `Le mot de passe doit faire au moins ${PASSWORD_MIN} caractères.`;
|
||||
if (password.length > PASSWORD_MAX) return `Le mot de passe est trop long (max ${PASSWORD_MAX} caractères).`;
|
||||
if (!/[A-Z]/.test(password)) return "Le mot de passe doit contenir au moins une lettre majuscule.";
|
||||
if (!/[0-9]/.test(password)) return "Le mot de passe doit contenir au moins un chiffre.";
|
||||
if (!/[^a-zA-Z0-9]/.test(password)) return "Le mot de passe doit contenir au moins un caractère spécial (ex: !, @, #, $, %...).";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================= PUBLIC API =========================
|
||||
|
||||
/** Vérifie si au moins un utilisateur existe */
|
||||
export function hasAnyUser(): boolean {
|
||||
return loadUsers().length > 0;
|
||||
}
|
||||
|
||||
/** Retourne tous les utilisateurs (sans les hash de mots de passe) */
|
||||
export function getAllUsers(): PublicUser[] {
|
||||
return loadUsers().map(({ id, username, role, createdAt, mustChangePassword, preferences }) => ({
|
||||
id, username, role, createdAt, mustChangePassword, preferences
|
||||
}));
|
||||
}
|
||||
|
||||
/** Retourne un user par ID (données internes, avec hash) */
|
||||
export function getUserById(id: string): User | null {
|
||||
return loadUsers().find(u => u.id === id) || null;
|
||||
}
|
||||
|
||||
/** Retourne un user par username (case-insensitive) */
|
||||
export function getUserByUsername(username: string): User | null {
|
||||
const lower = username.toLowerCase().trim();
|
||||
return loadUsers().find(u => u.username.toLowerCase() === lower) || null;
|
||||
}
|
||||
|
||||
/** Crée un nouvel utilisateur */
|
||||
export function createUser(username: string, password: string, role: 'admin' | 'user', mustChangePassword = false): { user: PublicUser; clearPassword: string } {
|
||||
// Validation
|
||||
const usernameError = validateUsername(username);
|
||||
if (usernameError) throw new Error(usernameError);
|
||||
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) throw new Error(passwordError);
|
||||
|
||||
const users = loadUsers();
|
||||
|
||||
// Unicité du username (case-insensitive)
|
||||
if (users.some(u => u.username.toLowerCase() === username.toLowerCase().trim())) {
|
||||
throw new Error('Ce nom d\'utilisateur est déjà pris.');
|
||||
}
|
||||
|
||||
const salt = crypto.randomBytes(SCRYPT_SALT_LEN);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newUser: User = {
|
||||
id: crypto.randomUUID(),
|
||||
username: username.trim(),
|
||||
passwordHash: hashPassword(password, salt),
|
||||
role,
|
||||
createdAt: now,
|
||||
passwordChangedAt: now,
|
||||
mustChangePassword,
|
||||
preferences: {},
|
||||
};
|
||||
|
||||
users.push(newUser);
|
||||
saveUsers(users);
|
||||
|
||||
console.log(`[UserStore] Utilisateur créé: ${newUser.username} (${newUser.role}), mustChangePassword: ${mustChangePassword}`);
|
||||
|
||||
return {
|
||||
user: { id: newUser.id, username: newUser.username, role: newUser.role, createdAt: newUser.createdAt, mustChangePassword: newUser.mustChangePassword, preferences: newUser.preferences },
|
||||
clearPassword: password,
|
||||
};
|
||||
}
|
||||
|
||||
/** Crée un utilisateur avec un mot de passe auto-généré */
|
||||
export function createUserWithGeneratedPassword(username: string, role: 'admin' | 'user'): { user: PublicUser; clearPassword: string } {
|
||||
const password = generatePassword(16);
|
||||
return createUser(username, password, role, true);
|
||||
}
|
||||
|
||||
/** Vérifie les identifiants et retourne le user (ou null) */
|
||||
export function verifyCredentials(username: string, password: string): User | null {
|
||||
const user = getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
// Anti user-enumeration : on fait un scrypt bidon pour uniformiser le temps de réponse
|
||||
dummyScrypt();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (verifyPasswordHash(password, user.passwordHash)) {
|
||||
return user;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Supprime un utilisateur par ID */
|
||||
export function deleteUser(id: string): boolean {
|
||||
const users = loadUsers();
|
||||
const userToDelete = users.find(u => u.id === id);
|
||||
|
||||
if (!userToDelete) throw new Error('Utilisateur introuvable.');
|
||||
|
||||
// Anti-lockout : interdire la suppression du dernier admin
|
||||
if (userToDelete.role === 'admin') {
|
||||
const adminCount = users.filter(u => u.role === 'admin').length;
|
||||
if (adminCount <= 1) {
|
||||
throw new Error('Impossible de supprimer le dernier administrateur.');
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = users.filter(u => u.id !== id);
|
||||
saveUsers(filtered);
|
||||
|
||||
console.log(`[UserStore] Utilisateur supprimé: ${userToDelete.username}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reset le mot de passe d'un utilisateur et active mustChangePassword */
|
||||
export function resetPassword(id: string): { clearPassword: string } {
|
||||
const users = loadUsers();
|
||||
const user = users.find(u => u.id === id);
|
||||
|
||||
if (!user) throw new Error('Utilisateur introuvable.');
|
||||
|
||||
const newPassword = generatePassword(16);
|
||||
const salt = crypto.randomBytes(SCRYPT_SALT_LEN);
|
||||
|
||||
user.passwordHash = hashPassword(newPassword, salt);
|
||||
user.passwordChangedAt = new Date().toISOString();
|
||||
user.mustChangePassword = true;
|
||||
|
||||
saveUsers(users);
|
||||
|
||||
console.log(`[UserStore] Mot de passe réinitialisé pour: ${user.username}`);
|
||||
return { clearPassword: newPassword };
|
||||
}
|
||||
|
||||
/** Change le mot de passe d'un utilisateur après vérification de la complexité */
|
||||
export function changeUserPassword(id: string, newPassword: string): void {
|
||||
const passwordError = validatePassword(newPassword);
|
||||
if (passwordError) throw new Error(passwordError);
|
||||
|
||||
const users = loadUsers();
|
||||
const user = users.find(u => u.id === id);
|
||||
|
||||
if (!user) throw new Error('Utilisateur introuvable.');
|
||||
|
||||
const salt = crypto.randomBytes(SCRYPT_SALT_LEN);
|
||||
user.passwordHash = hashPassword(newPassword, salt);
|
||||
user.passwordChangedAt = new Date().toISOString();
|
||||
user.mustChangePassword = false;
|
||||
|
||||
saveUsers(users);
|
||||
console.log(`[UserStore] Mot de passe modifié avec succès pour: ${user.username}`);
|
||||
}
|
||||
|
||||
/** Met à jour les préférences d'un utilisateur */
|
||||
export function updateUserPreferences(id: string, updates: Record<string, any>): void {
|
||||
const users = loadUsers();
|
||||
const user = users.find(u => u.id === id);
|
||||
|
||||
if (!user) throw new Error('Utilisateur introuvable.');
|
||||
|
||||
user.preferences = { ...user.preferences, ...updates };
|
||||
|
||||
saveUsers(users);
|
||||
console.log(`[UserStore] Préférences mises à jour pour: ${user.username}`);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "public", "views"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<%- include('partials/header') %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="content-wrapper">
|
||||
<section id="section-downloads" class="section">
|
||||
<div class="header-row">
|
||||
<h2>Téléchargements</h2>
|
||||
<button id="btn-refresh-downloads" class="btn-icon-only">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="downloads-list" class="downloads-container">
|
||||
<div class="empty-state">Aucun téléchargement actif</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Hydr'Hacked — Connexion</title>
|
||||
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#1a1a1a">
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Hydr'Hacked">
|
||||
<link rel="apple-touch-icon" href="/images/icone-192.png">
|
||||
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/lucide.min.js"></script>
|
||||
<script src="/app.auth.js"></script>
|
||||
<style>
|
||||
.error-msg {
|
||||
color: var(--primary);
|
||||
font-size: 0.95rem;
|
||||
margin-top: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="login-overlay" class="overlay active">
|
||||
<div class="login-box">
|
||||
<div class="logo-large">
|
||||
<img src="/images/logo_transparent.png" alt="Logo" class="icon-xl">
|
||||
<h1>Hydr'Hacked</h1>
|
||||
</div>
|
||||
<form id="login-form">
|
||||
<input type="text" id="login-username" placeholder="Nom d'utilisateur" required autofocus autocomplete="username">
|
||||
<div class="password-container">
|
||||
<input type="password" id="login-password" placeholder="Mot de passe" required autocomplete="current-password">
|
||||
<button type="button" class="toggle-password" tabindex="-1">
|
||||
<i data-lucide="eye" class="eye-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit">Connexion</button>
|
||||
</form>
|
||||
<div id="login-error" class="error-msg hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
if (window.AuthHelpers) AuthHelpers.initPasswordToggles();
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('login-username').value.trim();
|
||||
const password = document.getElementById('login-password').value;
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
|
||||
errorDiv.classList.add('hidden');
|
||||
errorDiv.textContent = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
// Redirection vers la page par défaut
|
||||
const defaultPage = document.cookie.split('; ').find(c => c.startsWith('defaultPage='));
|
||||
const target = defaultPage ? defaultPage.split('=')[1] : '/trending';
|
||||
window.location.href = target;
|
||||
} else {
|
||||
errorDiv.textContent = data.error || 'Identifiants invalides.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
} catch (err) {
|
||||
errorDiv.textContent = 'Erreur de connexion au serveur.';
|
||||
errorDiv.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<%- include('partials/header') %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="content-wrapper">
|
||||
<section id="section-manual" class="section">
|
||||
<div class="hero-header">
|
||||
<h2>Ajout Manuel JDownloader</h2>
|
||||
</div>
|
||||
|
||||
<div class="form-card" style="max-width: 600px; margin: 0 auto;">
|
||||
<h3 style="margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem;">Ajouter des liens manuellement</h3>
|
||||
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<label for="manual-title" style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: white;">Nom du Film ou de la Série</label>
|
||||
<input type="text" id="manual-title" placeholder="Ex: Inception, Breaking Bad S01..." style="width: 100%; padding: 12px; background: var(--bg-main); border: 1px solid var(--border); color: white; border-radius: var(--radius);">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1.25rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: white;">Type de contenu</label>
|
||||
<div class="manual-type-selector">
|
||||
<div class="type-option active" data-type="film">
|
||||
<i data-lucide="film" style="width: 18px; height: 18px;"></i>
|
||||
Film
|
||||
</div>
|
||||
<div class="type-option" data-type="series">
|
||||
<i data-lucide="tv" style="width: 18px; height: 18px;"></i>
|
||||
Série
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="manual-type" value="film">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label for="manual-links" style="display: block; font-weight: 600; margin-bottom: 0.5rem; color: white;">Liens (un lien par ligne)</label>
|
||||
<textarea id="manual-links" placeholder="Copiez-collez vos liens ici (1fichier, 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>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" id="btn-manual-submit" style="width: 100%; display: flex; justify-content: center; align-items: center; gap: 8px; padding: 12px 24px; font-weight: bold;">
|
||||
<i data-lucide="plus-circle"></i> Envoyer à JDownloader
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
@@ -0,0 +1,26 @@
|
||||
</div>
|
||||
|
||||
<div id="modal-overlay" class="modal-overlay hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title">Titre</h3>
|
||||
<button id="modal-close"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div id="modal-body" class="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast hidden">Notification</div>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(reg => console.log('SW enregistré!', reg.scope))
|
||||
.catch(err => console.log('SW échec:', err));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script src="/app.auth.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Hydr'Hacked</title>
|
||||
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#1a1a1a">
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Hydr'Hacked">
|
||||
<link rel="apple-touch-icon" href="/images/icone-192.png">
|
||||
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/lucide.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-container">
|
||||
@@ -0,0 +1,93 @@
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<script>
|
||||
(function() {
|
||||
if (localStorage.getItem('sidebar-collapsed') === 'true') {
|
||||
document.getElementById('sidebar').classList.add('collapsed');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div class="brand">
|
||||
<div class="brand-logo">
|
||||
<img src="/images/logo_transparent.png" alt="Logo" class="brand-icon">
|
||||
<span>Hydr'Hacked</span>
|
||||
</div>
|
||||
<button id="btn-sidebar-toggle" class="btn-sidebar-toggle" title="Réduire la barre latérale">
|
||||
<i data-lucide="chevron-left" id="sidebar-toggle-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<a href="/trending" style="text-decoration: none; color: inherit;">
|
||||
<li class="<%= page === 'trending' ? 'active' : '' %>" data-target="section-trending">
|
||||
<i data-lucide="flame"></i>
|
||||
<span>Tendances</span>
|
||||
</li>
|
||||
</a>
|
||||
<a href="/recent" style="text-decoration: none; color: inherit;">
|
||||
<li class="<%= page === 'recent' ? 'active' : '' %>" data-target="section-recent">
|
||||
<i data-lucide="clock"></i>
|
||||
<span>Ajouts récents</span>
|
||||
</li>
|
||||
</a>
|
||||
<a href="/search" style="text-decoration: none; color: inherit;">
|
||||
<li class="<%= page === 'search' ? 'active' : '' %>" data-target="section-search">
|
||||
<i data-lucide="search"></i>
|
||||
<span>Recherche</span>
|
||||
</li>
|
||||
</a>
|
||||
<a href="/downloads" style="text-decoration: none; color: inherit;">
|
||||
<li class="<%= page === 'downloads' ? 'active' : '' %>" data-target="section-downloads">
|
||||
<i data-lucide="download"></i>
|
||||
<span>Téléchargements</span>
|
||||
</li>
|
||||
</a>
|
||||
<a href="/manual" style="text-decoration: none; color: inherit;">
|
||||
<li class="<%= page === 'manual' ? 'active' : '' %>" data-target="section-manual">
|
||||
<i data-lucide="plus-circle"></i>
|
||||
<span>Ajout manuel</span>
|
||||
</li>
|
||||
</a>
|
||||
<% if (typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin') { %>
|
||||
<a href="/settings" style="text-decoration: none; color: inherit;">
|
||||
<li class="<%= page === 'settings' ? 'active' : '' %>" data-target="section-settings">
|
||||
<i data-lucide="settings"></i>
|
||||
<span>Paramètres</span>
|
||||
</li>
|
||||
</a>
|
||||
<% } %>
|
||||
</ul>
|
||||
<div class="nav-footer">
|
||||
<button id="btn-logout" class="btn-text">
|
||||
<i data-lucide="log-out"></i>
|
||||
<span>Déconnexion</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleBtn = document.getElementById('btn-sidebar-toggle');
|
||||
const toggleIcon = document.getElementById('sidebar-toggle-icon');
|
||||
|
||||
if (!sidebar || !toggleBtn || !toggleIcon) return;
|
||||
|
||||
const updateIcon = (isCollapsed) => {
|
||||
toggleIcon.setAttribute('data-lucide', isCollapsed ? 'chevron-right' : 'chevron-left');
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons({ root: toggleBtn });
|
||||
}
|
||||
};
|
||||
|
||||
// Sync initial icon state
|
||||
const isCollapsed = sidebar.classList.contains('collapsed');
|
||||
updateIcon(isCollapsed);
|
||||
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const willCollapse = !sidebar.classList.contains('collapsed');
|
||||
sidebar.classList.toggle('collapsed', willCollapse);
|
||||
localStorage.setItem('sidebar-collapsed', willCollapse ? 'true' : 'false');
|
||||
updateIcon(willCollapse);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<%- include('partials/header') %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="content-wrapper">
|
||||
<section id="section-recent" class="section">
|
||||
<div class="hero-header">
|
||||
<h2>Récemment Ajoutés</h2>
|
||||
<p>Les derniers ajouts disponibles sur la source</p>
|
||||
</div>
|
||||
|
||||
<div id="recent-grid" class="media-grid">
|
||||
<div class="loader-wrapper">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
@@ -0,0 +1,55 @@
|
||||
<%- include('partials/header') %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="content-wrapper">
|
||||
<section id="section-search" class="section">
|
||||
<div class="hero-header">
|
||||
<h2>Recherche Globale</h2>
|
||||
</div>
|
||||
|
||||
<div class="search-hero">
|
||||
<div class="search-input-wrapper">
|
||||
<i data-lucide="search" class="search-icon-inside"></i>
|
||||
<input type="text" id="search-input" placeholder="Titre du film, série...">
|
||||
<button id="btn-search-trigger" class="btn-search-action">Go</button>
|
||||
</div>
|
||||
<p style="font-size: 0.8rem; color: var(--text-sec); margin-top: 8px; text-align: center;">
|
||||
⚠️ <i>Recherche stricte (Seulement pour la LocalDB) : Saisissez exactement le titre du film/série recherché.</i>
|
||||
</p>
|
||||
<div class="filter-pills" style="margin-top: 15px;" id="search-filters-container">
|
||||
<label class="pill">
|
||||
<input type="radio" name="search-type" value="film" checked>
|
||||
<span>Films</span>
|
||||
</label>
|
||||
<label class="pill">
|
||||
<input type="radio" name="search-type" value="serie">
|
||||
<span>Séries</span>
|
||||
</label>
|
||||
<label class="pill localdb-filter hidden">
|
||||
<input type="radio" name="search-type" value="game">
|
||||
<span>Jeux</span>
|
||||
</label>
|
||||
<label class="pill localdb-filter hidden">
|
||||
<input type="radio" name="search-type" value="software">
|
||||
<span>Logiciels</span>
|
||||
</label>
|
||||
<label class="pill localdb-filter hidden">
|
||||
<input type="radio" name="search-type" value="book">
|
||||
<span>Livres/BD</span>
|
||||
</label>
|
||||
<label class="pill localdb-filter hidden">
|
||||
<input type="radio" name="search-type" value="music">
|
||||
<span>Musique</span>
|
||||
</label>
|
||||
<label class="pill localdb-filter hidden">
|
||||
<input type="radio" name="search-type" value="other">
|
||||
<span>Autres</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="search-results" class="media-grid"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
@@ -0,0 +1,128 @@
|
||||
<%- include('partials/header') %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="content-wrapper">
|
||||
<section id="section-settings" class="section">
|
||||
<div class="hero-header">
|
||||
<h2>Paramètres</h2>
|
||||
</div>
|
||||
|
||||
<div class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
|
||||
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
|
||||
<i data-lucide="database"></i> Sources de Recherche
|
||||
</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
|
||||
Sélectionnez la source principale (prioritaire) et les sources secondaires à utiliser.
|
||||
</p>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
|
||||
<i data-lucide="list-ordered" style="color: var(--primary); width: 18px; height: 18px;"></i> Priorité & Activation des Sources
|
||||
</label>
|
||||
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1.2rem;">
|
||||
Activez les sources et glissez-déposez pour définir leur ordre de priorité. La première source active (cochée) sera la source principale.
|
||||
</p>
|
||||
|
||||
<div id="sources-sortable-container" style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 1rem;">
|
||||
<!-- Les sources ordonnables seront injectées ici par JS -->
|
||||
</div>
|
||||
|
||||
<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 class="form-card" style="max-width: 750px; margin: 0 auto 1.5rem auto;">
|
||||
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem;">
|
||||
Page d'accueil par défaut</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
|
||||
Sélectionnez la page sur laquelle vous souhaitez être redirigé par défaut à l'ouverture de
|
||||
l'application.
|
||||
</p>
|
||||
<select id="select-default-page" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||
<option value="/trending">Tendances</option>
|
||||
<option value="/recent">Ajouts récents</option>
|
||||
<option value="/search">Recherche</option>
|
||||
<option value="/downloads">Téléchargements</option>
|
||||
<option value="/manual">Ajout manuel</option>
|
||||
<option value="/settings">Paramètres</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-card" style="max-width: 750px; margin: 0 auto;">
|
||||
<label style="display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
|
||||
<span style="font-weight: 600;">Intégration JDownloader</span>
|
||||
<input type="checkbox" id="toggle-jd" style="width: 20px; height: 20px;">
|
||||
</label>
|
||||
<p style="font-size: 0.85rem; color: var(--text-sec); margin-top: 10px;">Si désactivé, le lien
|
||||
1fichier sera affiché directement sous forme de pop-up sans être envoyé à JDownloader.</p>
|
||||
</div>
|
||||
|
||||
<% if (typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin') { %>
|
||||
<div class="form-card" style="max-width: 750px; margin: 1.5rem auto 0 auto;">
|
||||
<h3 style="margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
|
||||
<i data-lucide="users" style="width: 20px; height: 20px;"></i> Gestion des Utilisateurs
|
||||
</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
|
||||
Gérez les comptes utilisateurs. Seuls les administrateurs ont accès à cette section.
|
||||
</p>
|
||||
|
||||
<!-- Liste des utilisateurs -->
|
||||
<div id="admin-users-list" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 1.5rem;">
|
||||
<!-- Injecté par JS -->
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<div style="border-top: 1px solid var(--border); padding-top: 1rem;">
|
||||
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 8px;">
|
||||
<i data-lucide="user-plus" style="color: var(--primary); width: 16px; height: 16px;"></i> Ajouter un utilisateur
|
||||
</label>
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 12px; align-items: center;">
|
||||
<input type="text" id="admin-new-username" placeholder="Nom d'utilisateur"
|
||||
style="flex: 1; margin: 0; padding: 12px 16px;"
|
||||
minlength="3" maxlength="32" pattern="[a-zA-Z0-9_.\-]+">
|
||||
<select id="admin-new-role" style="width: auto; min-width: 130px; margin: 0; padding: 12px 16px;">
|
||||
<option value="user">Utilisateur</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="admin-add-user-btn" class="btn-action" style="width: 100%; padding: 12px 20px;">
|
||||
Créer l'utilisateur
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Modal pour afficher le mot de passe généré -->
|
||||
<div id="admin-password-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content" style="max-width: 420px;">
|
||||
<h3 style="margin-bottom: 1rem; display: flex; align-items: center; gap: 8px;">
|
||||
<i data-lucide="key" style="width: 20px; height: 20px; color: var(--primary);"></i>
|
||||
Mot de passe généré
|
||||
</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--text-sec); margin-bottom: 1rem;">
|
||||
Copiez ce mot de passe et transmettez-le à l'utilisateur. Il ne sera plus affiché.
|
||||
</p>
|
||||
<div id="admin-generated-password" style="background: var(--bg-main); border: 1px solid var(--border); border-radius: 8px; padding: 14px; font-family: monospace; font-size: 1.1rem; text-align: center; color: var(--primary); font-weight: 700; letter-spacing: 1px; user-select: all; cursor: text; word-break: break-all;"></div>
|
||||
<button id="admin-copy-password-btn" style="width: 100%; margin-top: 1rem; padding: 10px; border-radius: 8px; background: var(--bg-card); border: 1px solid var(--border); color: white; font-weight: 600; cursor: pointer;">
|
||||
Copier le mot de passe
|
||||
</button>
|
||||
<button id="admin-close-modal-btn" style="width: 100%; margin-top: 0.5rem; padding: 10px; border-radius: 8px; background: var(--primary); border: none; color: white; font-weight: 600; cursor: pointer;">
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 3rem; margin-bottom: 1rem;">
|
||||
<p style="font-size: 0.85rem; color: var(--text-sec); display: inline-flex; align-items: center; gap: 6px; background: var(--bg-card); padding: 6px 12px; border-radius: 20px; border: 1px solid var(--border);">
|
||||
<i data-lucide="info" style="width: 14px; height: 14px;"></i>
|
||||
Hydr'Hacked v1.4.8
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Hydr'Hacked — Installation</title>
|
||||
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#1a1a1a">
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Hydr'Hacked">
|
||||
<link rel="apple-touch-icon" href="/images/icone-192.png">
|
||||
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/lucide.min.js"></script>
|
||||
<script src="/app.auth.js"></script>
|
||||
<style>
|
||||
.setup-container {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: var(--bg-main);
|
||||
}
|
||||
.setup-box {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
}
|
||||
.setup-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.setup-logo img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.setup-logo h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0;
|
||||
}
|
||||
.setup-logo p {
|
||||
color: var(--text-sec);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.setup-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(229, 9, 20, 0.15);
|
||||
color: var(--primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(229, 9, 20, 0.3);
|
||||
}
|
||||
.setup-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.setup-form label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-sec);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.setup-form input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-main);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.setup-form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(229, 9, 20, 0.15);
|
||||
}
|
||||
.setup-form button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.setup-form button[type="submit"]:hover { background: #c40812; }
|
||||
.setup-form button[type="submit"]:active { transform: scale(0.98); }
|
||||
.setup-form button[type="submit"]:disabled {
|
||||
background: #4b5563;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.setup-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="setup-container">
|
||||
<div class="setup-box">
|
||||
<div class="setup-logo">
|
||||
<img src="/images/logo_transparent.png" alt="Logo">
|
||||
<h1>Hydr'Hacked</h1>
|
||||
<p>Configuration initiale</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<span class="setup-badge">
|
||||
<i data-lucide="shield" style="width: 14px; height: 14px;"></i>
|
||||
Création du compte administrateur
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="setup-error" style="margin-bottom: 1rem;">
|
||||
<%= error %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div id="setup-client-error" class="setup-error" style="margin-bottom: 1rem; display: none;"></div>
|
||||
|
||||
<form class="setup-form" id="setup-form" action="/setup" method="POST">
|
||||
<div>
|
||||
<label for="setup-username">Nom d'utilisateur</label>
|
||||
<input type="text" id="setup-username" name="username" placeholder="admin" required
|
||||
minlength="3" maxlength="32" pattern="[a-zA-Z0-9_.\-]+" autofocus autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="password-requirements" style="text-align: left;">
|
||||
<strong>Exigences du mot de passe :</strong>
|
||||
<ul style="margin-top: 8px;">
|
||||
<li id="req-length" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins 8 caractères</li>
|
||||
<li id="req-upper" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins une majuscule (A-Z)</li>
|
||||
<li id="req-number" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins un chiffre (0-9)</li>
|
||||
<li id="req-special" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Au moins un caractère spécial</li>
|
||||
<li id="req-match" class="invalid" style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem;"><span class="icon-holder"><i data-lucide="x" style="width:14px;height:14px;"></i></span> Mots de passe identiques</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="setup-password">Mot de passe</label>
|
||||
<div class="password-container">
|
||||
<input type="password" id="setup-password" name="password" placeholder="Faut sécuriser le compte !"
|
||||
required minlength="8" maxlength="128" autocomplete="new-password">
|
||||
<button type="button" class="toggle-password" tabindex="-1">
|
||||
<i data-lucide="eye" class="eye-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="setup-confirm">Confirmer le mot de passe</label>
|
||||
<div class="password-container">
|
||||
<input type="password" id="setup-confirm" name="confirmPassword" placeholder="Retapez le mot de passe"
|
||||
required minlength="8" maxlength="128" autocomplete="new-password">
|
||||
<button type="button" class="toggle-password" tabindex="-1">
|
||||
<i data-lucide="eye" class="eye-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" id="setup-submit-btn" disabled>Créer le compte administrateur</button>
|
||||
<div style="text-align: center; margin-top: 3rem; margin-bottom: 1rem;">
|
||||
<p style="font-size: 0.85rem; color: var(--text-sec); display: inline-flex; align-items: center; gap: 6px; background: var(--bg-card); padding: 6px 12px; border-radius: 20px; border: 1px solid var(--border);">
|
||||
<i data-lucide="info" style="width: 14px; height: 14px;"></i>
|
||||
Hydr'Hacked v1.4.8
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
// Initialize password visibility toggles using the shared library
|
||||
if (window.AuthHelpers) {
|
||||
AuthHelpers.initPasswordToggles();
|
||||
}
|
||||
|
||||
const passwordInput = document.getElementById('setup-password');
|
||||
const confirmInput = document.getElementById('setup-confirm');
|
||||
const submitBtn = document.getElementById('setup-submit-btn');
|
||||
|
||||
const validateSetupInputs = () => {
|
||||
if (!window.AuthHelpers) return;
|
||||
const val = passwordInput.value;
|
||||
const confirmVal = confirmInput.value;
|
||||
|
||||
// Use the shared library validation logic
|
||||
const statuses = AuthHelpers.validateComplexity(val, confirmVal);
|
||||
|
||||
// Use the shared library UI updater
|
||||
AuthHelpers.updateRequirementsUI(document.getElementById('setup-form'), statuses);
|
||||
|
||||
submitBtn.disabled = !statuses.allValid;
|
||||
};
|
||||
|
||||
passwordInput.addEventListener('input', validateSetupInputs);
|
||||
confirmInput.addEventListener('input', validateSetupInputs);
|
||||
|
||||
document.getElementById('setup-form').addEventListener('submit', function(e) {
|
||||
const password = passwordInput.value;
|
||||
const confirm = confirmInput.value;
|
||||
const errorDiv = document.getElementById('setup-client-error');
|
||||
|
||||
errorDiv.style.display = 'none';
|
||||
|
||||
if (password !== confirm) {
|
||||
e.preventDefault();
|
||||
errorDiv.textContent = 'Les mots de passe ne correspondent pas.';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
<%- include('partials/header') %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="content-wrapper">
|
||||
<section id="section-trending" class="section">
|
||||
<div class="hero-header">
|
||||
<h2>🔥 Tendances du Moment</h2>
|
||||
<p>Les contenus les plus populaires en ce moment</p>
|
||||
</div>
|
||||
|
||||
<div class="filter-pills" style="margin-bottom: 2rem;">
|
||||
<label class="pill">
|
||||
<input type="radio" name="trending-type" value="film" checked>
|
||||
<span>Films</span>
|
||||
</label>
|
||||
<label class="pill">
|
||||
<input type="radio" name="trending-type" value="serie">
|
||||
<span>Séries</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="trending-grid" class="media-grid">
|
||||
<div class="loader-wrapper">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
Reference in New Issue
Block a user